agentmux_srv\backend\storage/
store.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Store: generic OID-based CRUD for StoreObj types.
5//! Port of Go's pkg/wstore/wstore_dbops.go + wstore_dbsetup.go.
6//!
7//! Uses `Mutex<Connection>` matching Go's `MaxOpenConns(1)`.
8//! SQLite WAL mode + 5s busy timeout (same as Go).
9
10
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13
14use rusqlite::{params, Connection};
15use serde::{Deserialize, Serialize};
16
17use crate::backend::obj::{wave_obj_from_json, wave_obj_to_json, StoreObj};
18use crate::registry::{DefinitionStore, Registry};
19
20use super::error::StoreError;
21use super::migrations::{check_schema_compat, run_object_schema, stamp_version, OBJECT_SCHEMA_VERSION};
22
23/// SQLite-backed object store for StoreObj types.
24pub struct Store {
25    /// `pub(super)` so sibling subsystem modules (e.g. `memory_bundles`)
26    /// can take the lock. Each per-subsystem file adds methods to `Store`
27    /// via `impl Store {}` and needs the connection.
28    pub(super) conn: Mutex<Connection>,
29    /// Cross-version named-agent registry. `None` for in-memory test
30    /// stores; `Some` for production srv. Mutations to
31    /// `db_agent_instances` parallel-write to this registry when set.
32    /// See `docs/specs/SPEC_SHARED_AGENT_REGISTRY_2026_05_12.md`.
33    registry: Mutex<Option<Arc<Registry>>>,
34    /// GLOBAL (cross-channel) agent-definition store. `None` for in-memory
35    /// test stores and when the shared dir can't be resolved; `Some` for
36    /// production srv. Definition mutations mirror to it so an agent created
37    /// in one channel is visible in every channel (cross-channel agent
38    /// persistence, `docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md`).
39    def_registry: Mutex<Option<Arc<DefinitionStore>>>,
40    /// Base directory that named-instance `working_directory` values are
41    /// expressed **relative to** in the instance registry (write side:
42    /// `registry_mirror`; read side: the `listnamedagents` handler).
43    ///
44    /// This is the **current channel's** agents dir (`channels/<ch>/agents`,
45    /// i.e. `AGENTMUX_AGENTS_DIR`). It must be tracked separately from the
46    /// registry's own file location because P0.3 re-roots the registry to the
47    /// global `~/.agentmux/shared/agents/registry/` — once the registry no
48    /// longer sits under `channels/<ch>/agents/`, its parent (`agents_root()`)
49    /// stops coinciding with the channel agents dir, and using it to strip /
50    /// re-join `working_directory` would drop every live instance.
51    ///
52    /// In production it is wired from `AGENTMUX_AGENTS_DIR` in `main.rs`
53    /// (P0.3b), atomically with the re-root to the global shared registry —
54    /// which is why the wiring waited for the re-root: setting it earlier would
55    /// diverge in dev mode, where `AGENTMUX_AGENTS_DIR` ≠ the (then
56    /// channel-local) registry parent. `None` only for in-memory test stores
57    /// and odd envs where the var is unset; the accessor then falls back to the
58    /// registry's parent (which equals the channel agents dir in the pre-re-root
59    /// layout), so existing mirror tests are unchanged. When set, it is passed
60    /// in explicitly — never read from ambient env inside the Store — so tests
61    /// running inside an AgentMux pane don't pick up the host's
62    /// `AGENTMUX_AGENTS_DIR`. See
63    /// `docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md` (P0.3).
64    registry_agents_base: Mutex<Option<PathBuf>>,
65}
66
67impl Store {
68    /// Open a Store backed by a file on disk.
69    /// Configures WAL mode and 5s busy timeout (matching Go).
70    pub fn open(path: &Path) -> Result<Self, StoreError> {
71        let conn = Connection::open(path)?;
72        Self::configure_and_migrate(conn)
73    }
74
75    /// Open an in-memory Store for testing.
76    #[allow(dead_code)]
77    pub fn open_in_memory() -> Result<Self, StoreError> {
78        let conn = Connection::open_in_memory()?;
79        Self::configure_and_migrate(conn)
80    }
81
82    /// Crate-internal accessor for sibling modules that maintain their
83    /// own per-table CRUD via the `DroneStore` extension trait
84    /// pattern (see `agentmux-srv/src/drone/storage.rs`). Outside
85    /// callers must use the typed methods on this impl.
86    pub(crate) fn conn(&self) -> &Mutex<Connection> {
87        &self.conn
88    }
89
90    /// Run the `db_agents` consolidation backfill under the wstore's
91    /// exclusive connection lock. Idempotent — gated by a marker file in
92    /// `data_dir` (skip with `None` for tests).
93    pub fn run_agents_consolidate(
94        &self,
95        data_dir: Option<&Path>,
96    ) -> Result<super::agents_consolidate::ConsolidateStats, StoreError> {
97        let mut conn = self.conn.lock().unwrap();
98        super::agents_consolidate::run_consolidate_migration(&mut conn, data_dir)
99    }
100
101    /// Backfill any `db_agent_definitions` rows that are missing from
102    /// `db_agents`.  Not marker-gated — runs cheaply on every startup.
103    /// See `agents_consolidate::repair_def_gaps` for details.
104    pub fn repair_agent_def_gaps(&self) -> Result<usize, StoreError> {
105        let mut conn = self.conn.lock().unwrap();
106        super::agents_consolidate::repair_def_gaps(&mut *conn)
107    }
108
109    fn configure_and_migrate(conn: Connection) -> Result<Self, StoreError> {
110        conn.execute_batch(
111            // `foreign_keys=ON` is per-connection and defaults to OFF in
112            // SQLite. The v6 schema (`db_agent_identity_links`,
113            // `db_agent_instances`) relies on `ON DELETE CASCADE` to clean
114            // up junction rows and instances when a parent agent or
115            // identity is removed. Without this pragma on the production
116            // connection, cascades silently no-op; migration tests set it
117            // explicitly, which would have masked the gap.
118            "PRAGMA journal_mode=WAL;
119             PRAGMA busy_timeout=5000;
120             PRAGMA foreign_keys=ON;
121             PRAGMA synchronous=NORMAL;
122             PRAGMA cache_size=-8000;
123             PRAGMA mmap_size=268435456;
124             PRAGMA temp_store=MEMORY;",
125        )?;
126        // Safety lock BEFORE any migration side effects — the legacy-
127        // table rename + seed-insert steps in `run_object_schema` are
128        // mutating, so we must refuse to open a newer-schema DB before
129        // we touch it. (codex P1 on #1029 — fixes the original PR's
130        // check-after-migrate order that let a downgraded binary
131        // partially mutate a newer DB before the error fired.)
132        check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
133        run_object_schema(&conn)?;
134        stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
135        Ok(Self {
136            conn: Mutex::new(conn),
137            registry: Mutex::new(None),
138            def_registry: Mutex::new(None),
139            registry_agents_base: Mutex::new(None),
140        })
141    }
142
143    /// Attach a shared cross-version agent registry. Called once on
144    /// srv startup after `Store::open` and before the store is
145    /// wrapped in `Arc`. Mutations to `db_agent_instances` will then
146    /// parallel-write to the registry; the SQLite table remains the
147    /// authoritative read path for PR A.
148    pub fn set_registry(&self, registry: Arc<Registry>) {
149        *self.registry.lock().unwrap_or_else(|e| e.into_inner()) = Some(registry);
150    }
151
152    pub(super) fn registry(&self) -> Option<Arc<Registry>> {
153        self.registry
154            .lock()
155            .unwrap_or_else(|e| e.into_inner())
156            .clone()
157    }
158
159    /// Set the channel agents dir that instance `working_directory` values
160    /// are stored relative to (see the `registry_agents_base` field). Wired
161    /// from `AGENTMUX_AGENTS_DIR` in `main.rs` in P0.3b (atomically with the
162    /// registry re-root); until then it is exercised only by tests.
163    pub fn set_registry_agents_base(&self, base: PathBuf) {
164        *self
165            .registry_agents_base
166            .lock()
167            .unwrap_or_else(|e| e.into_inner()) = Some(base);
168    }
169
170    /// The base dir for instance `working_directory` relative paths.
171    ///
172    /// Returns the explicitly-set channel agents dir
173    /// ([`set_registry_agents_base`]) when present; otherwise falls back to
174    /// the registry's parent (`agents_root()`), which equals the channel
175    /// agents dir in the pre-re-root layout. Used symmetrically by the write
176    /// mirror and the read handler so the two never disagree on the anchor.
177    pub fn registry_agents_base(&self) -> Option<PathBuf> {
178        if let Some(base) = self
179            .registry_agents_base
180            .lock()
181            .unwrap_or_else(|e| e.into_inner())
182            .clone()
183        {
184            return Some(base);
185        }
186        self.registry()
187            .and_then(|r| r.agents_root().map(|p| p.to_path_buf()))
188    }
189
190    /// Public accessor for the cross-version named-agent registry.
191    /// Returns `None` when the registry couldn't be resolved at
192    /// startup (CI / unusual envs); callers must handle the absent
193    /// case by falling back to SQLite.
194    pub fn shared_agent_registry(&self) -> Option<Arc<Registry>> {
195        self.registry()
196    }
197
198    /// Attach the GLOBAL (cross-channel) agent-definition store. Called
199    /// once on srv startup after `Store::open`, before the store is
200    /// wrapped in `Arc`. Definition mutations then mirror to it.
201    pub fn set_def_registry(&self, def_registry: Arc<DefinitionStore>) {
202        *self.def_registry.lock().unwrap_or_else(|e| e.into_inner()) = Some(def_registry);
203    }
204
205    /// Public accessor for the global agent-definition store. `None` when
206    /// it couldn't be resolved at startup (CI / unusual envs / in-memory
207    /// tests); callers fall back to SQLite.
208    pub fn shared_def_registry(&self) -> Option<Arc<DefinitionStore>> {
209        self.def_registry
210            .lock()
211            .unwrap_or_else(|e| e.into_inner())
212            .clone()
213    }
214
215    /// Table name for a StoreObj type: `db_<otype>`.
216    fn table_name<T: StoreObj>() -> String {
217        format!("db_{}", T::get_otype())
218    }
219
220    /// Get a single object by OID. Returns `None` if not found.
221    pub fn get<T: StoreObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
222        let conn = self.conn.lock().unwrap();
223        let table = Self::table_name::<T>();
224        let mut stmt =
225            conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
226
227        let result = stmt.query_row(params![oid], |row| {
228            let version: i64 = row.get(0)?;
229            let data: Vec<u8> = row.get(1)?;
230            Ok((version, data))
231        });
232
233        match result {
234            Ok((version, data)) => {
235                let mut obj: T = wave_obj_from_json(&data)?;
236                obj.set_version(version);
237                Ok(Some(obj))
238            }
239            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
240            Err(e) => Err(StoreError::Sqlite(e)),
241        }
242    }
243
244    /// Get a single object, returning `StoreError::NotFound` if missing.
245    pub fn must_get<T: StoreObj>(&self, oid: &str) -> Result<T, StoreError> {
246        self.get::<T>(oid)?.ok_or(StoreError::NotFound)
247    }
248
249    /// Get a single object as raw JSON Value by otype and OID.
250    /// Used by GetObject/GetObjects to return data without strict struct deserialization.
251    pub fn get_raw(&self, otype: &str, oid: &str) -> Result<Option<serde_json::Value>, StoreError> {
252        let conn = self.conn.lock().unwrap();
253        let table = format!("db_{}", otype);
254        let mut stmt =
255            conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
256
257        let result = stmt.query_row(params![oid], |row| {
258            let version: i64 = row.get(0)?;
259            let data: Vec<u8> = row.get(1)?;
260            Ok((version, data))
261        });
262
263        match result {
264            Ok((version, data)) => {
265                let mut val: serde_json::Value = serde_json::from_slice(&data)
266                    .map_err(|e| StoreError::Json(e))?;
267                if let Some(obj) = val.as_object_mut() {
268                    obj.insert("version".to_string(), serde_json::json!(version));
269                    obj.insert("otype".to_string(), serde_json::json!(otype));
270                }
271                Ok(Some(val))
272            }
273            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
274            Err(e) => Err(StoreError::Sqlite(e)),
275        }
276    }
277
278    /// Check if an object exists (by otype and OID).
279    #[allow(dead_code)]
280    pub fn exists_raw(&self, otype: &str, oid: &str) -> Result<bool, StoreError> {
281        let conn = self.conn.lock().unwrap();
282        let table = format!("db_{}", otype);
283        let count: i64 = conn.query_row(
284            &format!("SELECT COUNT(*) FROM {table} WHERE oid = ?1"),
285            params![oid],
286            |row| row.get(0),
287        )?;
288        Ok(count > 0)
289    }
290
291    /// Insert a new object. Sets version to 1.
292    pub fn insert<T: StoreObj>(&self, obj: &mut T) -> Result<(), StoreError> {
293        let oid = obj.get_oid().to_string();
294        if oid.is_empty() {
295            return Err(StoreError::EmptyOID);
296        }
297
298        obj.set_version(1);
299        let data = wave_obj_to_json(obj)?;
300
301        let conn = self.conn.lock().unwrap();
302        let table = Self::table_name::<T>();
303        conn.execute(
304            &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
305            params![oid, data],
306        )?;
307
308        Ok(())
309    }
310
311    /// Update an existing object. Increments version atomically.
312    /// Returns the new version number.
313    pub fn update<T: StoreObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
314        let oid = obj.get_oid().to_string();
315        if oid.is_empty() {
316            return Err(StoreError::EmptyOID);
317        }
318
319        let data = wave_obj_to_json(obj)?;
320
321        let conn = self.conn.lock().unwrap();
322        let table = Self::table_name::<T>();
323
324        // Optimistic locking: increment version and return new value.
325        // Matches Go: `UPDATE ... SET version = version+1 ... RETURNING version`
326        let new_version: i64 = conn.query_row(
327            &format!(
328                "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
329            ),
330            params![data, oid],
331            |row| row.get(0),
332        )?;
333
334        obj.set_version(new_version);
335        Ok(new_version)
336    }
337
338    /// Update an object using raw JSON (bypasses struct deserialization).
339    /// Used by UpdateObject where the frontend sends the full replacement object.
340    /// This matches Go's generic map-based UpdateObject behavior.
341    pub fn update_raw(&self, otype: &str, oid: &str, value: &serde_json::Value) -> Result<i64, StoreError> {
342        if oid.is_empty() {
343            return Err(StoreError::EmptyOID);
344        }
345        let data = serde_json::to_vec(value)?;
346        let conn = self.conn.lock().unwrap();
347        let table = format!("db_{}", otype);
348        let new_version: i64 = conn.query_row(
349            &format!(
350                "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
351            ),
352            params![data, oid],
353            |row| row.get(0),
354        )?;
355        Ok(new_version)
356    }
357
358    /// Delete an object by OID.
359    #[allow(dead_code)]
360    pub fn delete<T: StoreObj>(&self, oid: &str) -> Result<(), StoreError> {
361        let conn = self.conn.lock().unwrap();
362        let table = Self::table_name::<T>();
363        conn.execute(
364            &format!("DELETE FROM {table} WHERE oid = ?1"),
365            params![oid],
366        )?;
367        Ok(())
368    }
369
370    /// Delete by otype string and OID (for dynamic dispatch).
371    /// Validates `otype` against `VALID_OTYPES` to prevent SQL injection.
372    #[allow(dead_code)]
373    pub fn delete_by_otype(&self, otype: &str, oid: &str) -> Result<(), StoreError> {
374        if !crate::backend::obj::VALID_OTYPES.contains(&otype) {
375            return Err(StoreError::Other(format!("unknown otype: {otype:?}")));
376        }
377        let conn = self.conn.lock().unwrap();
378        let table = format!("db_{otype}");
379        conn.execute(
380            &format!("DELETE FROM {table} WHERE oid = ?1"),
381            params![oid],
382        )?;
383        Ok(())
384    }
385
386    /// Get all objects of a given type.
387    pub fn get_all<T: StoreObj>(&self) -> Result<Vec<T>, StoreError> {
388        let conn = self.conn.lock().unwrap();
389        let table = Self::table_name::<T>();
390        let mut stmt = conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
391        let rows = stmt.query_map([], |row| {
392            let version: i64 = row.get(1)?;
393            let data: Vec<u8> = row.get(2)?;
394            Ok((version, data))
395        })?;
396
397        let mut result = Vec::new();
398        for row in rows {
399            let (version, data) = row?;
400            let mut obj: T = wave_obj_from_json(&data)?;
401            obj.set_version(version);
402            result.push(obj);
403        }
404        Ok(result)
405    }
406
407    /// Count objects of a given type.
408    #[allow(dead_code)]
409    pub fn count<T: StoreObj>(&self) -> Result<i64, StoreError> {
410        let conn = self.conn.lock().unwrap();
411        let table = Self::table_name::<T>();
412        let count: i64 =
413            conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
414                row.get(0)
415            })?;
416        Ok(count)
417    }
418
419    /// Execute multiple operations in a single SQLite transaction.
420    /// Acquires the Mutex once, wraps all operations in BEGIN/COMMIT.
421    /// On error, rolls back and returns the error.
422    ///
423    /// This is the key performance primitive — reduces N lock acquisitions
424    /// and N fsyncs to 1 each.
425    pub fn with_tx<F, R>(&self, f: F) -> Result<R, StoreError>
426    where
427        F: FnOnce(&StoreTx) -> Result<R, StoreError>,
428    {
429        let conn = self.conn.lock().unwrap();
430        conn.execute_batch("BEGIN")?;
431        let tx = StoreTx { conn: &conn };
432        match f(&tx) {
433            Ok(result) => {
434                conn.execute_batch("COMMIT")?;
435                Ok(result)
436            }
437            Err(e) => {
438                let _ = conn.execute_batch("ROLLBACK");
439                Err(e)
440            }
441        }
442    }
443}
444
445/// A borrowed connection handle for use inside [`Store::with_tx`].
446/// Provides the same CRUD methods as `Store` but operates on the
447/// already-locked connection without additional Mutex acquisition.
448pub struct StoreTx<'a> {
449    conn: &'a Connection,
450}
451
452impl<'a> StoreTx<'a> {
453    fn table_name<T: StoreObj>() -> String {
454        format!("db_{}", T::get_otype())
455    }
456
457    pub fn get<T: StoreObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
458        let table = Self::table_name::<T>();
459        let mut stmt =
460            self.conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
461
462        let result = stmt.query_row(params![oid], |row| {
463            let version: i64 = row.get(0)?;
464            let data: Vec<u8> = row.get(1)?;
465            Ok((version, data))
466        });
467
468        match result {
469            Ok((version, data)) => {
470                let mut obj: T = wave_obj_from_json(&data)?;
471                obj.set_version(version);
472                Ok(Some(obj))
473            }
474            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
475            Err(e) => Err(StoreError::Sqlite(e)),
476        }
477    }
478
479    pub fn must_get<T: StoreObj>(&self, oid: &str) -> Result<T, StoreError> {
480        self.get::<T>(oid)?.ok_or(StoreError::NotFound)
481    }
482
483    pub fn insert<T: StoreObj>(&self, obj: &mut T) -> Result<(), StoreError> {
484        let oid = obj.get_oid().to_string();
485        if oid.is_empty() {
486            return Err(StoreError::EmptyOID);
487        }
488
489        obj.set_version(1);
490        let data = wave_obj_to_json(obj)?;
491
492        let table = Self::table_name::<T>();
493        self.conn.execute(
494            &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
495            params![oid, data],
496        )?;
497
498        Ok(())
499    }
500
501    pub fn update<T: StoreObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
502        let oid = obj.get_oid().to_string();
503        if oid.is_empty() {
504            return Err(StoreError::EmptyOID);
505        }
506
507        let data = wave_obj_to_json(obj)?;
508
509        let table = Self::table_name::<T>();
510        let new_version: i64 = self.conn.query_row(
511            &format!(
512                "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
513            ),
514            params![data, oid],
515            |row| row.get(0),
516        )?;
517
518        obj.set_version(new_version);
519        Ok(new_version)
520    }
521
522    pub fn get_all<T: StoreObj>(&self) -> Result<Vec<T>, StoreError> {
523        let table = Self::table_name::<T>();
524        let mut stmt = self.conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
525        let rows = stmt.query_map([], |row| {
526            let version: i64 = row.get(1)?;
527            let data: Vec<u8> = row.get(2)?;
528            Ok((version, data))
529        })?;
530
531        let mut result = Vec::new();
532        for row in rows {
533            let (version, data) = row?;
534            let mut obj: T = wave_obj_from_json(&data)?;
535            obj.set_version(version);
536            result.push(obj);
537        }
538        Ok(result)
539    }
540
541    #[allow(dead_code)]
542    pub fn delete<T: StoreObj>(&self, oid: &str) -> Result<(), StoreError> {
543        let table = Self::table_name::<T>();
544        self.conn.execute(
545            &format!("DELETE FROM {table} WHERE oid = ?1"),
546            params![oid],
547        )?;
548        Ok(())
549    }
550}
551
552// Re-exports so existing `storage::store::*` imports keep working.
553pub use super::agents::{derive_slug, AgentDefinition, AgentInstance, InstanceStatus};
554pub use super::memory_bundles::Memory;
555pub use super::content::AgentContent;
556pub use super::history::AgentHistory;
557pub use super::skills::AgentSkill;
558
559// Identity system types.
560pub use super::identities::{
561    AgentIdentityLink, Identity, IdentityAccount, IdentityBinding, SecretRef,
562};
563
564
565
566
567// ====================================================================
568// Tests
569// ====================================================================
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::backend::obj::*;
575
576    fn make_store() -> Store {
577        Store::open_in_memory().unwrap()
578    }
579
580    #[test]
581    fn test_insert_and_get_client() {
582        let store = make_store();
583        let mut client = Client {
584            oid: "test-client-oid".to_string(),
585            version: 0,
586            windowids: vec!["w1".to_string()],
587            meta: MetaMapType::new(),
588            tosagreed: 1700000000000,
589            ..Default::default()
590        };
591        store.insert(&mut client).unwrap();
592        assert_eq!(client.get_version(), 1);
593
594        let loaded = store.must_get::<Client>("test-client-oid").unwrap();
595        assert_eq!(loaded.oid, "test-client-oid");
596        assert_eq!(loaded.version, 1);
597        assert_eq!(loaded.windowids, vec!["w1"]);
598        assert_eq!(loaded.tosagreed, 1700000000000);
599    }
600
601    #[test]
602    fn test_insert_and_get_window() {
603        let store = make_store();
604        let mut win = Window {
605            oid: "win-1".to_string(),
606            workspaceid: "ws-1".to_string(),
607            pos: Point { x: 10, y: 20 },
608            winsize: WinSize {
609                width: 800,
610                height: 600,
611            },
612            meta: MetaMapType::new(),
613            ..Default::default()
614        };
615        store.insert(&mut win).unwrap();
616
617        let loaded = store.must_get::<Window>("win-1").unwrap();
618        assert_eq!(loaded.workspaceid, "ws-1");
619        assert_eq!(loaded.pos.x, 10);
620        assert_eq!(loaded.winsize.width, 800);
621    }
622
623    #[test]
624    fn test_insert_and_get_workspace() {
625        let store = make_store();
626        let mut ws = Workspace {
627            oid: "ws-1".to_string(),
628            name: "Test WS".to_string(),
629            tabids: vec!["t1".to_string()],
630            activetabid: "t1".to_string(),
631            meta: MetaMapType::new(),
632            ..Default::default()
633        };
634        store.insert(&mut ws).unwrap();
635
636        let loaded = store.must_get::<Workspace>("ws-1").unwrap();
637        assert_eq!(loaded.name, "Test WS");
638        assert_eq!(loaded.tabids, vec!["t1"]);
639    }
640
641    #[test]
642    fn test_insert_and_get_tab() {
643        let store = make_store();
644        let mut tab = Tab {
645            oid: "tab-1".to_string(),
646            name: "Shell".to_string(),
647            layoutstate: "ls-1".to_string(),
648            blockids: vec!["b1".to_string()],
649            meta: MetaMapType::new(),
650            ..Default::default()
651        };
652        store.insert(&mut tab).unwrap();
653
654        let loaded = store.must_get::<Tab>("tab-1").unwrap();
655        assert_eq!(loaded.name, "Shell");
656    }
657
658    #[test]
659    fn test_insert_and_get_block() {
660        let store = make_store();
661        let mut block = Block {
662            oid: "blk-1".to_string(),
663            parentoref: "tab:tab-1".to_string(),
664            meta: {
665                let mut m = MetaMapType::new();
666                m.insert("view".into(), serde_json::json!("term"));
667                m
668            },
669            ..Default::default()
670        };
671        store.insert(&mut block).unwrap();
672
673        let loaded = store.must_get::<Block>("blk-1").unwrap();
674        assert_eq!(loaded.parentoref, "tab:tab-1");
675        assert_eq!(loaded.meta.get("view").unwrap(), "term");
676    }
677
678    #[test]
679    fn test_insert_and_get_layout_state() {
680        let store = make_store();
681        // Phase E.4.B Phase 2 — uses typed LayoutNode (was a junk JSON blob).
682        let mut ls = LayoutState {
683            oid: "ls-1".to_string(),
684            rootnode: Some(crate::backend::obj::LayoutNode {
685                id: "n1".into(),
686                flex_direction: crate::backend::obj::FlexDirection::Row,
687                size: 1.0,
688                children: Vec::new(),
689                data: None,
690                ..Default::default()
691            }),
692            magnifiednodeid: "n1".to_string(),
693            ..Default::default()
694        };
695        store.insert(&mut ls).unwrap();
696
697        let loaded = store.must_get::<LayoutState>("ls-1").unwrap();
698        assert_eq!(loaded.magnifiednodeid, "n1");
699        assert!(loaded.rootnode.is_some());
700    }
701
702    #[test]
703    fn test_get_nonexistent_returns_none() {
704        let store = make_store();
705        let result = store.get::<Client>("nonexistent").unwrap();
706        assert!(result.is_none());
707    }
708
709    #[test]
710    fn test_must_get_nonexistent_returns_error() {
711        let store = make_store();
712        let result = store.must_get::<Client>("nonexistent");
713        assert!(matches!(result, Err(StoreError::NotFound)));
714    }
715
716    #[test]
717    fn test_update_increments_version() {
718        let store = make_store();
719        let mut client = Client {
720            oid: "c1".to_string(),
721            meta: MetaMapType::new(),
722            ..Default::default()
723        };
724        store.insert(&mut client).unwrap();
725        assert_eq!(client.version, 1);
726
727        client.windowids = vec!["w-new".to_string()];
728        let v2 = store.update(&mut client).unwrap();
729        assert_eq!(v2, 2);
730        assert_eq!(client.version, 2);
731
732        let v3 = store.update(&mut client).unwrap();
733        assert_eq!(v3, 3);
734    }
735
736    #[test]
737    fn test_delete() {
738        let store = make_store();
739        let mut client = Client {
740            oid: "del-me".to_string(),
741            meta: MetaMapType::new(),
742            ..Default::default()
743        };
744        store.insert(&mut client).unwrap();
745        assert!(store.get::<Client>("del-me").unwrap().is_some());
746
747        store.delete::<Client>("del-me").unwrap();
748        assert!(store.get::<Client>("del-me").unwrap().is_none());
749    }
750
751    #[test]
752    fn test_get_all() {
753        let store = make_store();
754        for i in 0..3 {
755            let mut tab = Tab {
756                oid: format!("tab-{i}"),
757                name: format!("Tab {i}"),
758                meta: MetaMapType::new(),
759                ..Default::default()
760            };
761            store.insert(&mut tab).unwrap();
762        }
763
764        let all = store.get_all::<Tab>().unwrap();
765        assert_eq!(all.len(), 3);
766    }
767
768    #[test]
769    fn test_count() {
770        let store = make_store();
771        assert_eq!(store.count::<Client>().unwrap(), 0);
772
773        let mut c = Client {
774            oid: "c1".to_string(),
775            meta: MetaMapType::new(),
776            ..Default::default()
777        };
778        store.insert(&mut c).unwrap();
779        assert_eq!(store.count::<Client>().unwrap(), 1);
780    }
781
782    #[test]
783    fn test_insert_empty_oid_fails() {
784        let store = make_store();
785        let mut client = Client {
786            oid: String::new(),
787            meta: MetaMapType::new(),
788            ..Default::default()
789        };
790        let result = store.insert(&mut client);
791        assert!(matches!(result, Err(StoreError::EmptyOID)));
792    }
793
794    #[test]
795    fn test_with_tx_commits_on_success() {
796        let store = make_store();
797        store
798            .with_tx(|tx| {
799                let mut ws = Workspace {
800                    oid: "ws-tx".to_string(),
801                    name: "TX Workspace".to_string(),
802                    meta: MetaMapType::new(),
803                    ..Default::default()
804                };
805                tx.insert(&mut ws)?;
806
807                let mut tab = Tab {
808                    oid: "tab-tx".to_string(),
809                    // tabN naming convention per SPEC_TAB_GAPS_AND_NAMING_2026_04_25.
810                    name: "tab1".to_string(),
811                    layoutstate: "ls-tx".to_string(),
812                    meta: MetaMapType::new(),
813                    ..Default::default()
814                };
815                tx.insert(&mut tab)?;
816
817                // Update workspace to reference tab
818                ws.tabids.push("tab-tx".to_string());
819                tx.update(&mut ws)?;
820
821                Ok(())
822            })
823            .unwrap();
824
825        // Verify everything committed
826        let ws = store.must_get::<Workspace>("ws-tx").unwrap();
827        assert_eq!(ws.name, "TX Workspace");
828        assert_eq!(ws.tabids, vec!["tab-tx"]);
829        assert_eq!(ws.version, 2); // insert=v1, update=v2
830
831        let tab = store.must_get::<Tab>("tab-tx").unwrap();
832        assert_eq!(tab.name, "tab1");
833    }
834
835    #[test]
836    fn test_with_tx_rollbacks_on_error() {
837        let store = make_store();
838        let result: Result<(), StoreError> = store.with_tx(|tx| {
839            let mut ws = Workspace {
840                oid: "ws-rollback".to_string(),
841                name: "Should Not Exist".to_string(),
842                meta: MetaMapType::new(),
843                ..Default::default()
844            };
845            tx.insert(&mut ws)?;
846
847            // Force an error
848            Err(StoreError::Other("intentional failure".to_string()))
849        });
850        assert!(result.is_err());
851
852        // Verify the insert was rolled back
853        let ws = store.get::<Workspace>("ws-rollback").unwrap();
854        assert!(ws.is_none());
855    }
856
857    #[test]
858    fn test_agent_def_insert_collision_resolves_at_runtime() {
859        // Two agents whose names derive to the same slug must both
860        // insert successfully, with the second getting a `-2` suffix.
861        // This exercises the runtime collision-resolution path in
862        // agent_def_insert (separate from the migration backfill path
863        // tested in migrations.rs).
864        let store = make_store();
865
866        let mut a1 = AgentDefinition {
867            id: "id-a".to_string(),
868            slug: String::new(),
869            name: "Agent X".to_string(),
870            icon: "✦".to_string(),
871            provider: "claude".to_string(),
872            description: String::new(),
873            working_directory: String::new(),
874            shell: String::new(),
875            provider_flags: String::new(),
876            auto_start: 0,
877            restart_on_crash: 0,
878            idle_timeout_minutes: 0,
879            created_at: 0,
880            agent_type: "host".to_string(),
881            environment: String::new(),
882            agent_bus_id: String::new(),
883            is_seeded: 0,
884            accounts: String::new(),
885            parent_id: String::new(),
886            branch_label: String::new(),
887            updated_at: 0,
888            user_hidden: 0,
889            container_image: String::new(),
890            container_volumes: "[]".to_string(),
891            container_name: String::new(),
892        };
893        store.agent_def_insert(&mut a1).unwrap();
894        // "Agent X" → "agent-x"
895        assert_eq!(a1.slug, "agent-x");
896
897        let mut a2 = AgentDefinition {
898            id: "id-b".to_string(),
899            // Different surface form, derives to the same slug
900            name: "agent x".to_string(),
901            ..a1.clone()
902        };
903        a2.slug = String::new();
904        store.agent_def_insert(&mut a2).unwrap();
905        assert_eq!(a2.slug, "agent-x-2");
906
907        let mut a3 = AgentDefinition {
908            id: "id-c".to_string(),
909            name: "AGENT-X".to_string(),
910            ..a1.clone()
911        };
912        a3.slug = String::new();
913        store.agent_def_insert(&mut a3).unwrap();
914        assert_eq!(a3.slug, "agent-x-3");
915
916        // Verify the underlying rows actually got written
917        let listed = store.agent_def_list().unwrap();
918        let slugs: Vec<&str> = listed.iter().map(|a| a.slug.as_str()).collect();
919        assert!(slugs.contains(&"agent-x"));
920        assert!(slugs.contains(&"agent-x-2"));
921        assert!(slugs.contains(&"agent-x-3"));
922    }
923
924    #[test]
925    fn test_agent_def_insert_explicit_slug_collision_resolves() {
926        // When a caller passes an explicit (non-empty) slug that
927        // already exists, agent_def_insert still resolves the collision
928        // via suffixing — guards against the seed pre-loading the
929        // same slug twice or any other "I know the slug" path.
930        let store = make_store();
931
932        let mut a1 = AgentDefinition {
933            id: "id-a".to_string(),
934            slug: "explicit".to_string(),
935            name: "First".to_string(),
936            icon: "✦".to_string(),
937            provider: "claude".to_string(),
938            description: String::new(),
939            working_directory: String::new(),
940            shell: String::new(),
941            provider_flags: String::new(),
942            auto_start: 0,
943            restart_on_crash: 0,
944            idle_timeout_minutes: 0,
945            created_at: 0,
946            agent_type: "host".to_string(),
947            environment: String::new(),
948            agent_bus_id: String::new(),
949            is_seeded: 0,
950            accounts: String::new(),
951            parent_id: String::new(),
952            branch_label: String::new(),
953            updated_at: 0,
954            user_hidden: 0,
955            container_image: String::new(),
956            container_volumes: "[]".to_string(),
957            container_name: String::new(),
958        };
959        store.agent_def_insert(&mut a1).unwrap();
960        assert_eq!(a1.slug, "explicit");
961
962        let mut a2 = AgentDefinition {
963            id: "id-b".to_string(),
964            ..a1.clone()
965        };
966        a2.slug = "explicit".to_string();
967        store.agent_def_insert(&mut a2).unwrap();
968        assert_eq!(a2.slug, "explicit-2");
969    }
970
971    // ---- v6 identity / instance CRUD ----
972
973    fn v6_test_store() -> Store {
974        Store::open_in_memory().unwrap()
975    }
976
977    fn sample_account(id: &str, provider: &str) -> IdentityAccount {
978        IdentityAccount {
979            id: id.to_string(),
980            name: format!("asaf-{provider}"),
981            provider: provider.to_string(),
982            kind: "pat".to_string(),
983            display_name: "".to_string(),
984            secret_ref: SecretRef::Env { env_var: format!("{}_TOKEN", provider.to_uppercase()) },
985            context: serde_json::json!({"username": "asaf"}),
986            status: "unknown".to_string(),
987            created_at: 0,
988            updated_at: 0,
989        }
990    }
991
992    fn sample_agent(id: &str, slug: &str) -> AgentDefinition {
993        AgentDefinition {
994            id: id.to_string(),
995            slug: slug.to_string(),
996            name: id.to_string(),
997            icon: "✦".to_string(),
998            provider: "claude".to_string(),
999            description: "".to_string(),
1000            working_directory: "".to_string(),
1001            shell: "".to_string(),
1002            provider_flags: "".to_string(),
1003            auto_start: 0,
1004            restart_on_crash: 0,
1005            idle_timeout_minutes: 0,
1006            created_at: 0,
1007            agent_type: "host".to_string(),
1008            environment: "".to_string(),
1009            agent_bus_id: "".to_string(),
1010            is_seeded: 0,
1011            accounts: String::new(),
1012            parent_id: String::new(),
1013            branch_label: String::new(),
1014            updated_at: 0,
1015            user_hidden: 0,
1016            container_image: String::new(),
1017            container_volumes: "[]".to_string(),
1018            container_name: String::new(),
1019        }
1020    }
1021
1022    #[test]
1023    fn test_identity_upsert_round_trip() {
1024        let store = v6_test_store();
1025        let acct = sample_account("id-gh", "github");
1026        store.identity_upsert(&acct).unwrap();
1027
1028        let fetched = store.identity_get("id-gh").unwrap().expect("row");
1029        assert_eq!(fetched.name, "asaf-github");
1030        assert_eq!(fetched.provider, "github");
1031        assert!(matches!(fetched.secret_ref, SecretRef::Env { ref env_var } if env_var == "GITHUB_TOKEN"));
1032        assert_eq!(fetched.context["username"], "asaf");
1033    }
1034
1035    #[test]
1036    fn test_identity_list_filtered_by_provider() {
1037        let store = v6_test_store();
1038        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1039        store.identity_upsert(&sample_account("id-aws", "aws")).unwrap();
1040        store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
1041
1042        let all = store.identity_list(None).unwrap();
1043        assert_eq!(all.len(), 3);
1044        let gh = store.identity_list(Some("github")).unwrap();
1045        assert_eq!(gh.len(), 2);
1046        assert!(gh.iter().all(|a| a.provider == "github"));
1047    }
1048
1049    #[test]
1050    fn test_identity_delete() {
1051        let store = v6_test_store();
1052        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1053        assert!(store.identity_delete("id-gh").unwrap());
1054        assert!(store.identity_get("id-gh").unwrap().is_none());
1055        // Second delete is a no-op.
1056        assert!(!store.identity_delete("id-gh").unwrap());
1057    }
1058
1059    #[test]
1060    fn test_agent_identity_link_and_unlink() {
1061        let store = v6_test_store();
1062        let mut agent = sample_agent("ag1", "agent-x");
1063        store.agent_def_insert(&mut agent).unwrap();
1064        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1065
1066        store.agent_identity_link("ag1", "id-gh", "github").unwrap();
1067        let links = store.agent_identity_list_for_agent("ag1").unwrap();
1068        assert_eq!(links.len(), 1);
1069        assert_eq!(links[0].account_id, "id-gh");
1070
1071        // Re-link with a different account overwrites (one account per provider per agent)
1072        store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
1073        store.agent_identity_link("ag1", "id-gh2", "github").unwrap();
1074        let links = store.agent_identity_list_for_agent("ag1").unwrap();
1075        assert_eq!(links.len(), 1);
1076        assert_eq!(links[0].account_id, "id-gh2");
1077
1078        assert!(store.agent_identity_unlink("ag1", "github").unwrap());
1079        assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
1080    }
1081
1082    #[test]
1083    fn test_agent_identity_cascade_on_agent_delete() {
1084        let store = v6_test_store();
1085        let mut agent = sample_agent("ag1", "agent-x");
1086        store.agent_def_insert(&mut agent).unwrap();
1087        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1088        store.agent_identity_link("ag1", "id-gh", "github").unwrap();
1089
1090        store.agent_def_delete("ag1").unwrap();
1091        assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
1092    }
1093
1094    #[test]
1095    fn test_instance_create_update_filter() {
1096        let store = v6_test_store();
1097        let mut agent = sample_agent("def1", "agent-x");
1098        store.agent_def_insert(&mut agent).unwrap();
1099
1100        let inst = AgentInstance {
1101            id: "inst1".to_string(),
1102            definition_id: "def1".to_string(),
1103            parent_instance_id: String::new(),
1104            block_id: "block-abc".to_string(),
1105            session_id: String::new(),
1106            status: InstanceStatus::Running.as_str().to_string(),
1107            github_context: String::new(),
1108            started_at: 1000,
1109            ended_at: 0,
1110            created_at: 1000,
1111            identity_id: String::new(),
1112            memory_id: String::new(),
1113            instance_name: String::new(),
1114            working_directory: String::new(),
1115            display_hidden: false,
1116        };
1117        store.instance_create(&inst).unwrap();
1118
1119        let fetched = store.instance_get("inst1").unwrap().expect("row");
1120        assert_eq!(fetched.block_id, "block-abc");
1121        assert_eq!(fetched.status, "running");
1122
1123        // Update status → stopped
1124        let mut updated = fetched.clone();
1125        updated.status = InstanceStatus::Stopped.as_str().to_string();
1126        updated.ended_at = 2000;
1127        assert!(store.instance_update(&updated).unwrap());
1128        assert_eq!(store.instance_get("inst1").unwrap().unwrap().status, "stopped");
1129
1130        // Filter queries
1131        let all = store.instance_list(None, None).unwrap();
1132        assert_eq!(all.len(), 1);
1133        let by_def = store.instance_list(Some("def1"), None).unwrap();
1134        assert_eq!(by_def.len(), 1);
1135        let running = store.instance_list(None, Some("running")).unwrap();
1136        assert_eq!(running.len(), 0);
1137        let stopped = store.instance_list(None, Some("stopped")).unwrap();
1138        assert_eq!(stopped.len(), 1);
1139    }
1140
1141    #[test]
1142    fn test_instance_update_partial() {
1143        use crate::backend::storage::InstanceUpdate;
1144        let store = v6_test_store();
1145        let mut agent = sample_agent("defp", "agent-p");
1146        store.agent_def_insert(&mut agent).unwrap();
1147        let inst = AgentInstance {
1148            id: "instp".to_string(),
1149            definition_id: "defp".to_string(),
1150            parent_instance_id: String::new(),
1151            block_id: "block-1".to_string(),
1152            session_id: "sess-1".to_string(),
1153            status: InstanceStatus::Running.as_str().to_string(),
1154            github_context: "ctx-1".to_string(),
1155            started_at: 1000,
1156            ended_at: 0,
1157            created_at: 1000,
1158            identity_id: String::new(),
1159            memory_id: String::new(),
1160            instance_name: String::new(),
1161            working_directory: String::new(),
1162            display_hidden: false,
1163        };
1164        store.instance_create(&inst).unwrap();
1165
1166        // Update ONLY status — other columns must be preserved.
1167        let fresh = store
1168            .instance_update_partial(
1169                "instp",
1170                &InstanceUpdate { status: Some("stopped".into()), ..Default::default() },
1171            )
1172            .unwrap()
1173            .expect("row");
1174        assert_eq!(fresh.status, "stopped");
1175        assert_eq!(fresh.block_id, "block-1", "block_id must be untouched");
1176        assert_eq!(fresh.session_id, "sess-1", "session_id must be untouched");
1177        assert_eq!(fresh.github_context, "ctx-1", "github_context must be untouched");
1178
1179        // Update ONLY session_id — status from the prior write persists.
1180        let fresh = store
1181            .instance_update_partial(
1182                "instp",
1183                &InstanceUpdate { session_id: Some("sess-2".into()), ..Default::default() },
1184            )
1185            .unwrap()
1186            .expect("row");
1187        assert_eq!(fresh.session_id, "sess-2");
1188        assert_eq!(fresh.status, "stopped", "status from prior partial write persists");
1189
1190        // `Some("")` explicitly clears a string column.
1191        let fresh = store
1192            .instance_update_partial(
1193                "instp",
1194                &InstanceUpdate { github_context: Some(String::new()), ..Default::default() },
1195            )
1196            .unwrap()
1197            .expect("row");
1198        assert_eq!(fresh.github_context, "", "Some(\"\") clears");
1199
1200        // All-`None` no-op returns the unchanged row (NOT None — that's
1201        // reserved for not-found so the handler can tell them apart).
1202        let noop = store
1203            .instance_update_partial("instp", &InstanceUpdate::default())
1204            .unwrap();
1205        assert!(noop.is_some(), "no-op on an existing id returns the row");
1206        assert_eq!(noop.unwrap().session_id, "sess-2");
1207
1208        // Not-found returns None.
1209        let missing = store
1210            .instance_update_partial("nope", &InstanceUpdate { status: Some("x".into()), ..Default::default() })
1211            .unwrap();
1212        assert!(missing.is_none(), "not-found returns None");
1213    }
1214
1215    #[test]
1216    fn test_agent_def_list_orders_by_last_used() {
1217        let store = v6_test_store();
1218        // Three definitions; none launched yet.
1219        let mut a = sample_agent("def-a", "agent-a");
1220        let mut b = sample_agent("def-b", "agent-b");
1221        let mut c = sample_agent("def-c", "agent-c");
1222        store.agent_def_insert(&mut a).unwrap();
1223        store.agent_def_insert(&mut b).unwrap();
1224        store.agent_def_insert(&mut c).unwrap();
1225
1226        let mk = |id: &str, def: &str, started: i64| AgentInstance {
1227            id: id.to_string(),
1228            definition_id: def.to_string(),
1229            parent_instance_id: String::new(),
1230            block_id: String::new(),
1231            session_id: String::new(),
1232            status: InstanceStatus::Running.as_str().to_string(),
1233            github_context: String::new(),
1234            started_at: started,
1235            ended_at: 0,
1236            created_at: started,
1237            identity_id: String::new(),
1238            memory_id: String::new(),
1239            instance_name: String::new(),
1240            working_directory: String::new(),
1241            display_hidden: false,
1242        };
1243        // Launch def-a, then def-b later. def-c is never launched.
1244        store.instance_create(&mk("i-a", "def-a", 500)).unwrap();
1245        store.instance_create(&mk("i-b", "def-b", 600)).unwrap();
1246
1247        let ids = |s: &Store| -> Vec<String> {
1248            s.agent_def_list().unwrap().into_iter().map(|d| d.id).collect()
1249        };
1250        // Most-recently-launched first; never-launched (def-c) last.
1251        assert_eq!(ids(&store), vec!["def-b", "def-a", "def-c"]);
1252
1253        // A newer launch of def-a flips it above def-b (MAX(started_at)).
1254        store.instance_create(&mk("i-a2", "def-a", 700)).unwrap();
1255        assert_eq!(ids(&store), vec!["def-a", "def-b", "def-c"]);
1256    }
1257
1258    #[test]
1259    fn test_instance_cascade_on_definition_delete() {
1260        let store = v6_test_store();
1261        let mut agent = sample_agent("def1", "agent-x");
1262        store.agent_def_insert(&mut agent).unwrap();
1263        let inst = AgentInstance {
1264            id: "inst1".to_string(),
1265            definition_id: "def1".to_string(),
1266            parent_instance_id: String::new(),
1267            block_id: String::new(),
1268            session_id: String::new(),
1269            status: "running".to_string(),
1270            github_context: String::new(),
1271            started_at: 0,
1272            ended_at: 0,
1273            created_at: 0,
1274            identity_id: String::new(),
1275            memory_id: String::new(),
1276            instance_name: String::new(),
1277            working_directory: String::new(),
1278            display_hidden: false,
1279        };
1280        store.instance_create(&inst).unwrap();
1281
1282        store.agent_def_delete("def1").unwrap();
1283        assert!(store.instance_get("inst1").unwrap().is_none());
1284    }
1285
1286    #[test]
1287    fn test_instance_status_enum_roundtrip() {
1288        for s in &[
1289            InstanceStatus::Running,
1290            InstanceStatus::Paused,
1291            InstanceStatus::Stopped,
1292            InstanceStatus::Crashed,
1293            InstanceStatus::Detached,
1294        ] {
1295            assert_eq!(Some(*s), InstanceStatus::parse(s.as_str()));
1296        }
1297        assert_eq!(None, InstanceStatus::parse("nonsense"));
1298    }
1299
1300    // ── v7 — Identity bundle accessors ───────────────────────────────────
1301
1302    #[test]
1303    fn test_bundle_identity_lifecycle() {
1304        let store = make_store();
1305
1306        // Blank singleton always present.
1307        let initial = store.bundle_identity_list().unwrap();
1308        assert_eq!(initial.len(), 1);
1309        assert!(initial[0].is_blank);
1310        assert_eq!(initial[0].id, "blank");
1311
1312        // Upsert a user identity.
1313        let work = Identity {
1314            id: "id-work".to_string(),
1315            name: "Work".to_string(),
1316            description: "Office laptop credentials".to_string(),
1317            is_blank: false,
1318            created_at: 100,
1319            updated_at: 100,
1320        };
1321        store.bundle_identity_upsert(&work).unwrap();
1322
1323        // List orders user identities first, blank last.
1324        let listed = store.bundle_identity_list().unwrap();
1325        assert_eq!(listed.len(), 2);
1326        assert_eq!(listed[0].id, "id-work");
1327        assert_eq!(listed[1].id, "blank");
1328
1329        // Get round-trip.
1330        let fetched = store.bundle_identity_get("id-work").unwrap().unwrap();
1331        assert_eq!(fetched.name, "Work");
1332
1333        // Refuse to delete the blank singleton.
1334        let blank_delete = store.bundle_identity_delete("blank");
1335        assert!(blank_delete.is_err());
1336
1337        // Delete the user identity.
1338        assert!(store.bundle_identity_delete("id-work").unwrap());
1339        assert_eq!(store.bundle_identity_list().unwrap().len(), 1);
1340    }
1341
1342    #[test]
1343    fn test_bundle_identity_bindings_round_trip() {
1344        let store = make_store();
1345
1346        // Need an account to bind.
1347        let acct = IdentityAccount {
1348            id: "acct-1".to_string(),
1349            name: "asaf-github".to_string(),
1350            provider: "github".to_string(),
1351            kind: "pat".to_string(),
1352            display_name: String::new(),
1353            secret_ref: SecretRef::Env {
1354                env_var: "GITHUB_TOKEN".to_string(),
1355            },
1356            context: serde_json::json!({}),
1357            status: "unknown".to_string(),
1358            created_at: 0,
1359            updated_at: 0,
1360        };
1361        store.identity_upsert(&acct).unwrap();
1362
1363        let identity = Identity {
1364            id: "id-work".to_string(),
1365            name: "Work".to_string(),
1366            description: String::new(),
1367            is_blank: false,
1368            created_at: 0,
1369            updated_at: 0,
1370        };
1371        store.bundle_identity_upsert(&identity).unwrap();
1372
1373        // Bind, list, unbind.
1374        store
1375            .bundle_identity_bind("id-work", "github", "acct-1")
1376            .unwrap();
1377        let bindings = store.bundle_identity_bindings("id-work").unwrap();
1378        assert_eq!(bindings.len(), 1);
1379        assert_eq!(bindings[0].provider, "github");
1380        assert_eq!(bindings[0].account_id, "acct-1");
1381
1382        // Re-bind same provider replaces the account.
1383        let acct2 = IdentityAccount {
1384            id: "acct-2".to_string(),
1385            name: "asaf-github-2".to_string(),
1386            provider: "github".to_string(),
1387            kind: "pat".to_string(),
1388            display_name: String::new(),
1389            secret_ref: SecretRef::Env {
1390                env_var: "GITHUB_TOKEN_ALT".to_string(),
1391            },
1392            context: serde_json::json!({}),
1393            status: "unknown".to_string(),
1394            created_at: 0,
1395            updated_at: 0,
1396        };
1397        store.identity_upsert(&acct2).unwrap();
1398        store
1399            .bundle_identity_bind("id-work", "github", "acct-2")
1400            .unwrap();
1401        let rebound = store.bundle_identity_bindings("id-work").unwrap();
1402        assert_eq!(rebound.len(), 1);
1403        assert_eq!(rebound[0].account_id, "acct-2");
1404
1405        // Unbind.
1406        assert!(store.bundle_identity_unbind("id-work", "github").unwrap());
1407        assert_eq!(
1408            store.bundle_identity_bindings("id-work").unwrap().len(),
1409            0
1410        );
1411    }
1412
1413    #[test]
1414    fn test_bundle_identity_bindings_cascade_on_account_delete() {
1415        let store = make_store();
1416
1417        let acct = IdentityAccount {
1418            id: "acct-1".to_string(),
1419            name: "asaf-github".to_string(),
1420            provider: "github".to_string(),
1421            kind: "pat".to_string(),
1422            display_name: String::new(),
1423            secret_ref: SecretRef::Env {
1424                env_var: "GITHUB_TOKEN".to_string(),
1425            },
1426            context: serde_json::json!({}),
1427            status: "unknown".to_string(),
1428            created_at: 0,
1429            updated_at: 0,
1430        };
1431        store.identity_upsert(&acct).unwrap();
1432
1433        let identity = Identity {
1434            id: "id-work".to_string(),
1435            name: "Work".to_string(),
1436            description: String::new(),
1437            is_blank: false,
1438            created_at: 0,
1439            updated_at: 0,
1440        };
1441        store.bundle_identity_upsert(&identity).unwrap();
1442        store
1443            .bundle_identity_bind("id-work", "github", "acct-1")
1444            .unwrap();
1445
1446        // Deleting the account cascades the binding row.
1447        store.identity_delete("acct-1").unwrap();
1448        assert_eq!(
1449            store.bundle_identity_bindings("id-work").unwrap().len(),
1450            0
1451        );
1452    }
1453
1454    // ── v7 — Memory bundle accessors ─────────────────────────────────────
1455
1456    #[test]
1457    fn test_bundle_memory_lifecycle() {
1458        let store = make_store();
1459
1460        // Blank singleton always present.
1461        let initial = store.bundle_memory_list().unwrap();
1462        assert_eq!(initial.len(), 1);
1463        assert!(initial[0].is_blank);
1464        assert_eq!(initial[0].id, "blank");
1465
1466        // Upsert a user memory.
1467        let coder = Memory {
1468            id: "mem-coder".to_string(),
1469            name: "Claude-coder".to_string(),
1470            description: "Pair-programming setup".to_string(),
1471            is_blank: false,
1472            is_global: false,
1473            provider: "claude".to_string(),
1474            model: "claude-sonnet-4-6".to_string(),
1475            instructions: "You are a careful refactorer.".to_string(),
1476            context_files: "[]".to_string(),
1477            mcp_servers: "[]".to_string(),
1478            skills: "[]".to_string(),
1479            sort_order: 0,
1480            created_at: 100,
1481            updated_at: 100,
1482        };
1483        store.bundle_memory_upsert(&coder).unwrap();
1484
1485        let listed = store.bundle_memory_list().unwrap();
1486        assert_eq!(listed.len(), 2);
1487        assert_eq!(listed[0].id, "mem-coder");
1488        assert_eq!(listed[1].id, "blank");
1489
1490        let fetched = store.bundle_memory_get("mem-coder").unwrap().unwrap();
1491        assert_eq!(fetched.provider, "claude");
1492        assert_eq!(fetched.instructions, "You are a careful refactorer.");
1493
1494        // Refuse to delete the blank singleton.
1495        assert!(store.bundle_memory_delete("blank").is_err());
1496
1497        // Delete the user memory.
1498        assert!(store.bundle_memory_delete("mem-coder").unwrap());
1499        assert_eq!(store.bundle_memory_list().unwrap().len(), 1);
1500    }
1501
1502    #[test]
1503    fn test_global_brain_order_and_format() {
1504        let store = make_store();
1505
1506        let mk = |id: &str, name: &str, order: i64| Memory {
1507            id: id.to_string(),
1508            name: name.to_string(),
1509            description: String::new(),
1510            is_blank: false,
1511            is_global: true,
1512            provider: String::new(),
1513            model: String::new(),
1514            instructions: format!("rules for {name}"),
1515            context_files: "[]".to_string(),
1516            mcp_servers: "[]".to_string(),
1517            skills: "[]".to_string(),
1518            sort_order: order,
1519            created_at: 0,
1520            updated_at: 0,
1521        };
1522        // Insert out of order: B at 0, A at 1.
1523        store.bundle_memory_upsert(&mk("g-a", "Alpha", 1)).unwrap();
1524        store.bundle_memory_upsert(&mk("g-b", "Beta", 0)).unwrap();
1525
1526        // list_global orders by sort_order: Beta (0) then Alpha (1).
1527        let g = store.bundle_memory_list_global().unwrap();
1528        assert_eq!(
1529            g.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1530            vec!["g-b", "g-a"]
1531        );
1532
1533        // Reorder to [Alpha, Beta].
1534        let updated = store
1535            .bundle_memory_reorder(&["g-a".to_string(), "g-b".to_string()])
1536            .unwrap();
1537        assert_eq!(updated, 2);
1538        let g = store.bundle_memory_list_global().unwrap();
1539        assert_eq!(
1540            g.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1541            vec!["g-a", "g-b"]
1542        );
1543
1544        // Editing a bundle via upsert must NOT disturb its sort_order.
1545        let mut edited = g[0].clone();
1546        edited.instructions = "edited".to_string();
1547        edited.sort_order = 999; // upsert ON CONFLICT ignores this
1548        store.bundle_memory_upsert(&edited).unwrap();
1549        let g = store.bundle_memory_list_global().unwrap();
1550        assert_eq!(g[0].id, "g-a", "edit must keep position");
1551        assert_eq!(g[0].sort_order, 0, "sort_order owned by reorder, not upsert");
1552
1553        // The injection block carries [Workspace] headings in order.
1554        let block = super::super::format_global_brain_block(&g);
1555        let expected = "# [Workspace] Alpha\n\nedited\n\n---\n\n# [Workspace] Beta\n\nrules for Beta";
1556        assert_eq!(block, expected);
1557    }
1558
1559    // ---- Registry parallel-write mirror (PR A) ----
1560
1561    fn make_named_inst(id: &str, name: &str, agents_root: &Path) -> AgentInstance {
1562        // working_directory must sit under <agents_root>/<slug> so the
1563        // relative-path resolver picks it up.
1564        let wd = agents_root.join(format!("{name}-fixture")).to_string_lossy().to_string();
1565        AgentInstance {
1566            id: id.to_string(),
1567            definition_id: "def-mirror".to_string(),
1568            parent_instance_id: String::new(),
1569            block_id: String::new(),
1570            session_id: String::new(),
1571            status: "running".to_string(),
1572            github_context: String::new(),
1573            started_at: 1_000,
1574            ended_at: 0,
1575            created_at: 900,
1576            identity_id: String::new(),
1577            memory_id: String::new(),
1578            instance_name: name.to_string(),
1579            working_directory: wd,
1580            display_hidden: false,
1581        }
1582    }
1583
1584    fn store_with_registry() -> (tempfile::TempDir, Store, Arc<crate::registry::Registry>) {
1585        let tmp = tempfile::tempdir().unwrap();
1586        let agents_root = tmp.path().join("agents");
1587        std::fs::create_dir_all(&agents_root).unwrap();
1588        let reg_root = agents_root.join("registry");
1589        let reg = Arc::new(crate::registry::Registry::open(reg_root).unwrap());
1590        let store = Store::open_in_memory().unwrap();
1591        store.set_registry(reg.clone());
1592        // Satisfy the FK from db_agent_instances.definition_id.
1593        let mut agent = sample_agent("def-mirror", "mirror");
1594        store.agent_def_insert(&mut agent).unwrap();
1595        (tmp, store, reg)
1596    }
1597
1598    #[test]
1599    fn instance_create_named_writes_registry_file() {
1600        let (tmp, store, reg) = store_with_registry();
1601        let agents_root = tmp.path().join("agents");
1602        let inst = make_named_inst("inst-1", "demo", &agents_root);
1603        store.instance_create(&inst).unwrap();
1604        let records = reg.list_active().unwrap();
1605        assert_eq!(records.len(), 1);
1606        assert_eq!(records[0].data.instance_id, "inst-1");
1607        assert_eq!(records[0].data.instance_name, "demo");
1608        assert_eq!(records[0].data.identity_id, None);
1609        assert_eq!(records[0].data.memory_id, None);
1610        assert_eq!(records[0].data.working_dir, "demo-fixture");
1611        // P0.4: the live mirror stamps the current channel agents base so a
1612        // different channel can reconstruct the absolute working_directory.
1613        assert_eq!(
1614            records[0].data.source_agents_base.as_deref(),
1615            Some(agents_root.to_string_lossy().as_ref())
1616        );
1617    }
1618
1619    #[test]
1620    fn registry_agents_base_falls_back_to_registry_parent() {
1621        // With no explicit base, the accessor returns the registry's parent
1622        // (= the channel agents dir in the pre-re-root layout). This is why
1623        // existing mirror tests are unchanged by the decoupling.
1624        let (_tmp, store, _reg) = store_with_registry();
1625        let base = store.registry_agents_base().unwrap();
1626        assert!(base.ends_with("agents"), "fallback is the registry parent");
1627        assert!(!base.ends_with("registry"));
1628    }
1629
1630    #[test]
1631    fn registry_agents_base_override_decouples_from_registry_parent() {
1632        // Simulates the P0.3 re-root: the registry lives at a GLOBAL root
1633        // whose parent (`agents_root()` = shared/agents) is NOT the channel
1634        // agents dir. The explicit base (the channel agents dir, i.e.
1635        // AGENTMUX_AGENTS_DIR) must win, so a working_directory under the
1636        // channel dir still mirrors with the correct relative path instead of
1637        // being dropped as "not under the registry parent".
1638        let tmp = tempfile::tempdir().unwrap();
1639        let global_reg_root = tmp.path().join("shared").join("agents").join("registry");
1640        let reg = Arc::new(crate::registry::Registry::open(global_reg_root).unwrap());
1641        let store = Store::open_in_memory().unwrap();
1642        store.set_registry(reg.clone());
1643        let channel_agents = tmp.path().join("channels").join("local-x").join("agents");
1644        std::fs::create_dir_all(&channel_agents).unwrap();
1645        store.set_registry_agents_base(channel_agents.clone());
1646        // Sanity: the override is NOT the registry's parent.
1647        assert_ne!(
1648            store.registry_agents_base().unwrap(),
1649            reg.agents_root().unwrap()
1650        );
1651        // FK satisfy.
1652        let mut agent = sample_agent("def-mirror", "mirror");
1653        store.agent_def_insert(&mut agent).unwrap();
1654        // Instance working dir under the CHANNEL agents dir.
1655        let inst = make_named_inst("inst-base", "demo", &channel_agents);
1656        store.instance_create(&inst).unwrap();
1657        let records = reg.list_active().unwrap();
1658        assert_eq!(records.len(), 1, "instance mirrors via the explicit base");
1659        // Relative path stripped against the channel agents dir, not the
1660        // registry parent (shared/agents) — which wouldn't contain it.
1661        assert_eq!(records[0].data.working_dir, "demo-fixture");
1662    }
1663
1664    /// Production-shaped store: registry at `<tmp>/shared/agents/registry` (so the
1665    /// mirror derives the GLOBAL workspace root `<tmp>/agents` via the registry
1666    /// root's 3rd ancestor), with the per-channel base set to
1667    /// `<tmp>/channels/<ch>/agents` (= AGENTMUX_AGENTS_DIR).
1668    fn store_with_global_registry() -> (tempfile::TempDir, Store, Arc<crate::registry::Registry>) {
1669        let tmp = tempfile::tempdir().unwrap();
1670        std::fs::create_dir_all(tmp.path().join("agents")).unwrap();
1671        let reg_root = tmp.path().join("shared").join("agents").join("registry");
1672        let reg = Arc::new(crate::registry::Registry::open(reg_root).unwrap());
1673        let store = Store::open_in_memory().unwrap();
1674        store.set_registry(reg.clone());
1675        let channel_agents = tmp.path().join("channels").join("local-x").join("agents");
1676        std::fs::create_dir_all(&channel_agents).unwrap();
1677        store.set_registry_agents_base(channel_agents);
1678        let mut agent = sample_agent("def-mirror", "mirror");
1679        store.agent_def_insert(&mut agent).unwrap();
1680        (tmp, store, reg)
1681    }
1682
1683    #[test]
1684    fn mirror_anchors_global_workspace() {
1685        // The bug this fixes: agent workspaces are GLOBAL (`<home>/agents/<name>`),
1686        // NOT under the per-channel agents dir. The live mirror must anchor on the
1687        // global root (derived from the registry root) and mirror the agent — not
1688        // drop it as "not representable" (the live-write twin of #1393).
1689        let (tmp, store, reg) = store_with_global_registry();
1690        let global_agents = tmp.path().join("agents"); // <home>/agents
1691        let inst = make_named_inst("inst-g", "qooma", &global_agents);
1692        store.instance_create(&inst).unwrap();
1693        let records = reg.list_active().unwrap();
1694        assert_eq!(records.len(), 1, "global workspace must mirror, not be dropped");
1695        assert_eq!(records[0].data.working_dir, "qooma-fixture");
1696        assert_eq!(
1697            records[0].data.source_agents_base.as_deref(),
1698            Some(global_agents.to_string_lossy().as_ref()),
1699            "anchored on the GLOBAL workspace root, not the channel base"
1700        );
1701    }
1702
1703    #[test]
1704    fn mirror_per_channel_legacy_workspace_still_works() {
1705        // A legacy in-channel workspace (under channels/<ch>/agents, not the global
1706        // root) still mirrors via the per-channel fallback.
1707        let (tmp, store, reg) = store_with_global_registry();
1708        let channel_agents = tmp.path().join("channels").join("local-x").join("agents");
1709        let inst = make_named_inst("inst-c", "legacy", &channel_agents);
1710        store.instance_create(&inst).unwrap();
1711        let records = reg.list_active().unwrap();
1712        assert_eq!(records.len(), 1);
1713        assert_eq!(records[0].data.working_dir, "legacy-fixture");
1714        assert_eq!(
1715            records[0].data.source_agents_base.as_deref(),
1716            Some(channel_agents.to_string_lossy().as_ref()),
1717            "fell back to the per-channel base"
1718        );
1719    }
1720
1721    #[test]
1722    fn mirror_skips_workspace_under_neither_root() {
1723        // A workspace under neither the global nor the channel root (a user cwd) is
1724        // skipped — parity with the migration's skip-unmappable behavior.
1725        let (tmp, store, reg) = store_with_global_registry();
1726        let outside = tmp.path().join("projects").join("foo");
1727        let inst = make_named_inst("inst-o", "stray", &outside);
1728        store.instance_create(&inst).unwrap();
1729        assert!(
1730            reg.list_active().unwrap().is_empty(),
1731            "non-agent workspace must not be mirrored"
1732        );
1733    }
1734
1735    #[test]
1736    fn instance_create_unnamed_does_not_mirror() {
1737        let (tmp, store, reg) = store_with_registry();
1738        let agents_root = tmp.path().join("agents");
1739        let mut inst = make_named_inst("inst-2", "demo2", &agents_root);
1740        inst.instance_name = String::new(); // unnamed
1741        store.instance_create(&inst).unwrap();
1742        assert!(reg.list_active().unwrap().is_empty());
1743    }
1744
1745    #[test]
1746    fn instance_set_hidden_retires_then_unretires() {
1747        let (tmp, store, reg) = store_with_registry();
1748        let agents_root = tmp.path().join("agents");
1749        let inst = make_named_inst("inst-3", "demo3", &agents_root);
1750        store.instance_create(&inst).unwrap();
1751        store.instance_set_hidden("inst-3", true).unwrap();
1752        assert!(reg.list_active().unwrap().is_empty());
1753        store.instance_set_hidden("inst-3", false).unwrap();
1754        assert_eq!(reg.list_active().unwrap().len(), 1);
1755    }
1756
1757    #[test]
1758    fn instance_update_refreshes_registry_record() {
1759        let (tmp, store, reg) = store_with_registry();
1760        let agents_root = tmp.path().join("agents");
1761        let inst = make_named_inst("inst-4", "demo4", &agents_root);
1762        store.instance_create(&inst).unwrap();
1763        let mut updated = inst.clone();
1764        updated.status = "paused".to_string();
1765        updated.session_id = "sess-xyz".to_string();
1766        store.instance_update(&updated).unwrap();
1767        // instance_update doesn't bump last_launched_at_ms (started_at is
1768        // immutable in the SQL update), so we just verify the record
1769        // still exists and is reachable.
1770        let records = reg.list_active().unwrap();
1771        assert_eq!(records.len(), 1);
1772        assert_eq!(records[0].data.instance_id, "inst-4");
1773    }
1774
1775    #[test]
1776    fn instance_delete_removes_registry_file() {
1777        let (tmp, store, reg) = store_with_registry();
1778        let agents_root = tmp.path().join("agents");
1779        let inst = make_named_inst("inst-5", "demo5", &agents_root);
1780        store.instance_create(&inst).unwrap();
1781        store.instance_delete("inst-5").unwrap();
1782        assert!(reg.list_active().unwrap().is_empty());
1783    }
1784
1785    #[test]
1786    fn instance_create_outside_agents_dir_skips_mirror() {
1787        let (tmp, store, reg) = store_with_registry();
1788        let mut inst = make_named_inst("inst-6", "demo6", tmp.path());
1789        // Override working_dir to live outside any "agents/" segment.
1790        inst.working_directory = tmp.path().join("projects").join("myrepo").to_string_lossy().to_string();
1791        store.instance_create(&inst).unwrap();
1792        // SQL row was written, but mirror is skipped because the working
1793        // dir can't be expressed as a relative subpath under agents/.
1794        assert!(reg.list_active().unwrap().is_empty());
1795    }
1796
1797    #[test]
1798    fn instance_create_user_path_with_agents_segment_is_skipped() {
1799        // Anchored-prefix check: a user-owned workspace at
1800        // `/home/user/code/agents/myproject` must NOT be mirrored. The
1801        // pre-fix scan-for-segment logic matched the inner "agents",
1802        // producing `working_dir = "myproject"` that would resolve to
1803        // `<shared>/agents/myproject` (wrong) when PR B reads the row.
1804        let (tmp, store, reg) = store_with_registry();
1805        // tmp is NOT under the registry's agents root, so this is a
1806        // user path that happens to include an "agents" component.
1807        let outside = tmp.path().join("code").join("agents").join("myproject");
1808        let mut inst = make_named_inst("inst-pathconfuse", "confuse", tmp.path());
1809        inst.working_directory = outside.to_string_lossy().to_string();
1810        store.instance_create(&inst).unwrap();
1811        assert!(reg.list_active().unwrap().is_empty(),
1812            "user path with inner 'agents' segment must not be mirrored");
1813    }
1814
1815    #[test]
1816    fn instance_create_continuation_row_does_not_mirror() {
1817        // Registry mirror filter (per the doc comment in
1818        // registry_upsert_if_named) intentionally lags the SQLite
1819        // dropdown filter: continuation rows are NOT mirrored to
1820        // registry, even though `instance_list_named` does return
1821        // them under Option E. Cross-version dedup is the planned
1822        // follow-up; until then the registry-sourced read path
1823        // doesn't have the SQLite ORDER BY/LIMIT affordance and so
1824        // continues to gate on parent_instance_id == ''.
1825        let (tmp, store, reg) = store_with_registry();
1826        let agents_root = tmp.path().join("agents");
1827        let parent = make_named_inst("inst-parent", "demoP", &agents_root);
1828        store.instance_create(&parent).unwrap();
1829        assert_eq!(reg.list_active().unwrap().len(), 1);
1830
1831        let mut child = make_named_inst("inst-child", "demoP", &agents_root);
1832        child.parent_instance_id = "inst-parent".to_string();
1833        store.instance_create(&child).unwrap();
1834        // Still only one record — the continuation row is NOT mirrored.
1835        let recs = reg.list_active().unwrap();
1836        assert_eq!(recs.len(), 1);
1837        assert_eq!(recs[0].data.instance_id, "inst-parent");
1838    }
1839
1840    #[test]
1841    fn instance_list_named_picker_mode_dedupes_continuation_chain() {
1842        // Discussion #1095 / SPEC_AGENT_ARCHITECTURE Phase 3b.1.
1843        // Before this dedup, a user with N continuations of one
1844        // logical agent saw N rows in "My Agents" (the user-visible
1845        // "4 Claudes" bug). Picker mode now collapses every chain
1846        // to its most-recent row.
1847        //
1848        // Test shape: head + one continuation, same name. Picker
1849        // returns ONE row — the continuation (latest started_at).
1850        // The chain's identity is preserved via `parent_instance_id`
1851        // on the surviving row.
1852        let (tmp, store, _reg) = store_with_registry();
1853        let agents_root = tmp.path().join("agents");
1854
1855        let mut head = make_named_inst("inst-head", "Maks", &agents_root);
1856        head.started_at = 100;
1857        store.instance_create(&head).unwrap();
1858
1859        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
1860        cont.parent_instance_id = "inst-head".to_string();
1861        cont.started_at = 200;
1862        store.instance_create(&cont).unwrap();
1863
1864        let picker_rows = store.instance_list_named(10, None, None, true).unwrap();
1865        assert_eq!(
1866            picker_rows.len(),
1867            1,
1868            "picker mode must collapse continuation chain to ONE entry"
1869        );
1870        assert_eq!(picker_rows[0].id, "inst-cont");
1871        // The surviving row keeps its real parent_instance_id —
1872        // callers needing to reconstruct the chain can still do so
1873        // by walking up from this row.
1874        assert_eq!(picker_rows[0].parent_instance_id, "inst-head");
1875
1876        // Definition-scoped picker mode — same dedup behavior.
1877        let scoped = store
1878            .instance_list_named(10, Some("def-mirror"), None, true)
1879            .unwrap();
1880        assert_eq!(scoped.len(), 1);
1881        assert_eq!(scoped[0].id, "inst-cont");
1882    }
1883
1884    #[test]
1885    fn instance_list_named_picker_mode_dedupes_long_chain() {
1886        // Regression for the 2026-05-27 "4 Claudes" report
1887        // (discussion #1095). The user's `db_agent_instances` had
1888        // 1 head + 4 continuations of the same agent. Picker mode
1889        // must return exactly 1 row — the most recent.
1890        let (tmp, store, _reg) = store_with_registry();
1891        let agents_root = tmp.path().join("agents");
1892
1893        let mut head = make_named_inst("inst-root", "Claude", &agents_root);
1894        head.started_at = 100;
1895        store.instance_create(&head).unwrap();
1896
1897        // 4 continuations chaining linearly off the head.
1898        for (i, parent) in [("c1", "inst-root"), ("c2", "c1"), ("c3", "c2"), ("c4", "c3")] {
1899            let mut c = make_named_inst(i, "Claude", &agents_root);
1900            c.parent_instance_id = parent.to_string();
1901            c.started_at = 100 + (i.chars().last().unwrap().to_digit(10).unwrap() as i64) * 100;
1902            store.instance_create(&c).unwrap();
1903        }
1904
1905        let picker_rows = store.instance_list_named(10, None, None, true).unwrap();
1906        assert_eq!(
1907            picker_rows.len(),
1908            1,
1909            "five-row chain (1 head + 4 continuations) must collapse to one entry"
1910        );
1911        assert_eq!(picker_rows[0].id, "c4", "newest continuation wins");
1912    }
1913
1914    #[test]
1915    fn instance_get_by_name_reads_from_db_agents() {
1916        // Phase 3b.2 — the consolidated `db_agents` table is the new
1917        // authority for named-agent lookups. After `instance_create`'s
1918        // dual-write, the helper must surface the agent by name with
1919        // the bindings populated from `db_agents`. Transient runtime
1920        // fields (block_id, session_id, status, started_at as launch
1921        // moment, ended_at, parent_instance_id) have no analog in the
1922        // consolidated row and come back as their type defaults.
1923        let (tmp, store, _reg) = store_with_registry();
1924        let agents_root = tmp.path().join("agents");
1925
1926        let mut head = make_named_inst("inst-1", "Maks", &agents_root);
1927        head.identity_id = "id-1".to_string();
1928        head.memory_id = "mem-1".to_string();
1929        head.github_context = "ghctx".to_string();
1930        store.instance_create(&head).unwrap();
1931
1932        let got = store
1933            .instance_get_by_name("Maks")
1934            .unwrap()
1935            .expect("should find by name");
1936        // Folded user-clone: the def and the instance share ONE
1937        // db_agents row keyed by def.id (def-mirror is_seeded=0 in
1938        // the test fixture). The caller still sees `definition_id`
1939        // populated — via the COALESCE in the query, an empty
1940        // parent_template_id resolves to the row's own id.
1941        assert_eq!(got.id, "def-mirror");
1942        assert_eq!(got.definition_id, "def-mirror");
1943        assert_eq!(got.instance_name, "Maks");
1944        assert_eq!(got.identity_id, "id-1");
1945        assert_eq!(got.memory_id, "mem-1");
1946        assert_eq!(got.github_context, "ghctx");
1947        assert!(!got.display_hidden);
1948        // Transient fields default to empty / 0 — see doc comment.
1949        assert_eq!(got.parent_instance_id, "");
1950        assert_eq!(got.block_id, "");
1951        assert_eq!(got.session_id, "");
1952        assert_eq!(got.status, "");
1953        assert_eq!(got.ended_at, 0);
1954    }
1955
1956    #[test]
1957    fn instance_get_by_name_returns_none_for_missing_name() {
1958        let (_tmp, store, _reg) = store_with_registry();
1959        assert!(store.instance_get_by_name("does-not-exist").unwrap().is_none());
1960    }
1961
1962    #[test]
1963    fn instance_get_by_name_excludes_hidden_rows() {
1964        // user_hidden = 1 (via display_hidden) must filter out — both
1965        // the launch modal collision detect and ContinueNamed depend
1966        // on "forgotten" agents being invisible.
1967        let (tmp, store, _reg) = store_with_registry();
1968        let agents_root = tmp.path().join("agents");
1969        let mut inst = make_named_inst("inst-hidden", "Ghost", &agents_root);
1970        inst.display_hidden = true;
1971        store.instance_create(&inst).unwrap();
1972        assert!(store.instance_get_by_name("Ghost").unwrap().is_none());
1973    }
1974
1975    #[test]
1976    fn instance_get_by_name_empty_input_returns_none() {
1977        let (_tmp, store, _reg) = store_with_registry();
1978        assert!(store.instance_get_by_name("").unwrap().is_none());
1979    }
1980
1981    #[test]
1982    fn continuation_mirrors_bindings_into_db_agents_user_clone_path() {
1983        // Codex P2 on PR #1110: when a named agent is continued with
1984        // different identity/memory/cwd/github_context, those bindings
1985        // must reach db_agents — otherwise `instance_get_by_name`
1986        // (which reads from db_agents) returns the head's stale data.
1987        // For user-clone defs (is_seeded=0), the projection is keyed
1988        // by def.id and the existing UPDATE handles both head and
1989        // continuation.
1990        let (tmp, store, _reg) = store_with_registry();
1991        let agents_root = tmp.path().join("agents");
1992
1993        // Original launch with one set of bindings.
1994        let mut head = make_named_inst("inst-head", "Maks", &agents_root);
1995        head.identity_id = "id-original".to_string();
1996        head.memory_id = "mem-original".to_string();
1997        store.instance_create(&head).unwrap();
1998
1999        // Continuation with NEW bindings.
2000        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2001        cont.parent_instance_id = "inst-head".to_string();
2002        cont.identity_id = "id-NEW".to_string();
2003        cont.memory_id = "mem-NEW".to_string();
2004        cont.github_context = "ghctx-NEW".to_string();
2005        store.instance_create(&cont).unwrap();
2006
2007        // The folded db_agents row reflects the continuation's bindings.
2008        let got = store.instance_get_by_name("Maks").unwrap().expect("found");
2009        assert_eq!(got.id, "def-mirror");
2010        assert_eq!(
2011            got.identity_id, "id-NEW",
2012            "continuation bindings must overwrite the head's"
2013        );
2014        assert_eq!(got.memory_id, "mem-NEW");
2015        assert_eq!(got.github_context, "ghctx-NEW");
2016    }
2017
2018    #[test]
2019    fn instance_get_by_name_collapses_continuation_chain_to_one_row() {
2020        // Continuations live in `db_agent_instances` (each launch =
2021        // one row). The Phase 3a dual-write projects them into ONE
2022        // canonical row in `db_agents` (keyed on the original head's
2023        // id, with bindings updated each continuation). So a 4-deep
2024        // chain with the same name surfaces as exactly one row here —
2025        // no MRU-row tie-breaking needed at this layer.
2026        let (tmp, store, _reg) = store_with_registry();
2027        let agents_root = tmp.path().join("agents");
2028
2029        let head = make_named_inst("inst-head", "Maks", &agents_root);
2030        store.instance_create(&head).unwrap();
2031
2032        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2033        cont.parent_instance_id = "inst-head".to_string();
2034        store.instance_create(&cont).unwrap();
2035
2036        let got = store.instance_get_by_name("Maks").unwrap().expect("found");
2037        // Only one db_agents row exists for the whole chain (the
2038        // folded user-clone row keyed by def-mirror); both
2039        // continuations updated its bindings.
2040        assert_eq!(got.id, "def-mirror");
2041        assert_eq!(got.instance_name, "Maks");
2042    }
2043
2044    #[test]
2045    fn instance_get_active_for_block_phase_3b4_resolves_via_block_meta() {
2046        // Phase 3b.4: instead of filtering db_agent_instances by
2047        // status, follow block.meta.agentId → db_agents directly.
2048        let (tmp, store, _reg) = store_with_registry();
2049        let agents_root = tmp.path().join("agents");
2050
2051        // Create the agent (folds into the def-mirror db_agents row).
2052        let mut inst = make_named_inst("inst-x", "Maks", &agents_root);
2053        inst.identity_id = "id-resolved".to_string();
2054        store.instance_create(&inst).unwrap();
2055
2056        // Create a Block whose meta points at the agent.
2057        let mut block = crate::backend::obj::Block {
2058            oid: "block-1".to_string(),
2059            parentoref: String::new(),
2060            version: 0,
2061            runtimeopts: None,
2062            stickers: None,
2063            meta: {
2064                let mut m = crate::backend::obj::MetaMapType::new();
2065                m.insert("view".to_string(), serde_json::json!("agent"));
2066                m.insert("agentId".to_string(), serde_json::json!("def-mirror"));
2067                m
2068            },
2069            subblockids: None,
2070        };
2071        store.insert(&mut block).unwrap();
2072
2073        let got = store
2074            .instance_get_active_for_block("block-1")
2075            .unwrap()
2076            .expect("expected the block's agent to resolve");
2077        assert_eq!(got.id, "def-mirror");
2078        assert_eq!(got.identity_id, "id-resolved");
2079        assert_eq!(got.block_id, "block-1"); // echoed from arg
2080        // Transient fields default — no status filtering needed.
2081        assert_eq!(got.status, "");
2082    }
2083
2084    #[test]
2085    fn instance_get_active_for_block_phase_3b4_returns_none_when_block_missing() {
2086        let (_tmp, store, _reg) = store_with_registry();
2087        assert!(store
2088            .instance_get_active_for_block("no-such-block")
2089            .unwrap()
2090            .is_none());
2091    }
2092
2093    #[test]
2094    fn instance_get_active_for_block_phase_3b4_returns_none_when_no_agent_id() {
2095        // Block exists but has no agentId in meta → resolver should
2096        // see None (no agent bound to this block).
2097        let (_tmp, store, _reg) = store_with_registry();
2098        let mut block = crate::backend::obj::Block {
2099            oid: "block-naked".to_string(),
2100            parentoref: String::new(),
2101            version: 0,
2102            runtimeopts: None,
2103            stickers: None,
2104            meta: crate::backend::obj::MetaMapType::new(),
2105            subblockids: None,
2106        };
2107        store.insert(&mut block).unwrap();
2108        assert!(store
2109            .instance_get_active_for_block("block-naked")
2110            .unwrap()
2111            .is_none());
2112    }
2113
2114    #[test]
2115    fn instance_get_active_for_block_phase_3b4_resolves_template_launch_via_legacy_fallback() {
2116        // Template launches store `agentId = template.id` in block
2117        // meta. Templates have `is_template = 1` and are filtered
2118        // out of the consolidated lookup, so the function falls back
2119        // to the legacy `db_agent_instances` query to find the inst
2120        // row that was created for this block.
2121        let (tmp, store, _reg) = store_with_registry();
2122        let agents_root = tmp.path().join("agents");
2123
2124        let mut tpl = sample_agent("tpl-coder", "tpl-coder");
2125        tpl.is_seeded = 1;
2126        store.agent_def_insert(&mut tpl).unwrap();
2127        let mut inst = make_named_inst("inst-tpl", "FromTemplate", &agents_root);
2128        inst.definition_id = "tpl-coder".to_string();
2129        inst.identity_id = "id-template-launch".to_string();
2130        inst.block_id = "block-tpl".to_string();
2131        store.instance_create(&inst).unwrap();
2132
2133        let mut block = crate::backend::obj::Block {
2134            oid: "block-tpl".to_string(),
2135            parentoref: String::new(),
2136            version: 0,
2137            runtimeopts: None,
2138            stickers: None,
2139            meta: {
2140                let mut m = crate::backend::obj::MetaMapType::new();
2141                m.insert("agentId".to_string(), serde_json::json!("tpl-coder"));
2142                m
2143            },
2144            subblockids: None,
2145        };
2146        store.insert(&mut block).unwrap();
2147
2148        let got = store
2149            .instance_get_active_for_block("block-tpl")
2150            .unwrap()
2151            .expect("template-launched block resolves via legacy fallback");
2152        assert_eq!(got.identity_id, "id-template-launch");
2153    }
2154
2155    #[test]
2156    fn instance_get_active_for_block_phase_3b4_ignores_stale_agent_instance_id() {
2157        // Codex P1 on PR #1114 round 3: pane reuse leaves
2158        // `agentInstanceId` stale (only `agentId` is cleared by
2159        // `backToPicker`). The resolver MUST NOT consult that key —
2160        // otherwise the prior agent's identity bleeds into the new
2161        // launch. We don't read `agentInstanceId` at all; the
2162        // current `agentId` is the source of truth.
2163        let (tmp, store, _reg) = store_with_registry();
2164        let agents_root = tmp.path().join("agents");
2165
2166        // Set up two distinct agents on different defs.
2167        let mut prior_def = sample_agent("def-prior", "prior");
2168        store.agent_def_insert(&mut prior_def).unwrap();
2169        let mut prior_inst = make_named_inst("inst-prior", "Prior", &agents_root);
2170        prior_inst.definition_id = "def-prior".to_string();
2171        prior_inst.identity_id = "id-PRIOR-DO-NOT-USE".to_string();
2172        store.instance_create(&prior_inst).unwrap();
2173
2174        // Current agent (the one the user just launched in the
2175        // reused pane). def-mirror is the fixture's pre-created def.
2176        let mut current_inst = make_named_inst("inst-current", "Current", &agents_root);
2177        current_inst.identity_id = "id-current-correct".to_string();
2178        store.instance_create(&current_inst).unwrap();
2179
2180        // Block has stale agentInstanceId pointing at the prior
2181        // agent, but current agentId. The resolver must use agentId.
2182        let mut block = crate::backend::obj::Block {
2183            oid: "block-reused".to_string(),
2184            parentoref: String::new(),
2185            version: 0,
2186            runtimeopts: None,
2187            stickers: None,
2188            meta: {
2189                let mut m = crate::backend::obj::MetaMapType::new();
2190                m.insert("agentId".to_string(), serde_json::json!("def-mirror"));
2191                m.insert(
2192                    "agentInstanceId".to_string(),
2193                    serde_json::json!("inst-prior"),
2194                );
2195                m
2196            },
2197            subblockids: None,
2198        };
2199        store.insert(&mut block).unwrap();
2200
2201        let got = store
2202            .instance_get_active_for_block("block-reused")
2203            .unwrap()
2204            .expect("must resolve to the current agent, not the stale instance id");
2205        assert_eq!(got.identity_id, "id-current-correct");
2206        assert_ne!(got.identity_id, "id-PRIOR-DO-NOT-USE");
2207    }
2208
2209    #[test]
2210    fn instance_get_active_for_block_phase_3b4_resolves_hidden_agents_for_existing_panes() {
2211        // Codex P2 on PR #1114 round 2: hiding a named agent
2212        // ("forget") is a picker-visibility concept — the pane that's
2213        // still bound to that agent must keep resolving credentials.
2214        // Otherwise the next command would silently fall back to
2215        // ambient creds the moment the user hides the agent.
2216        let (tmp, store, _reg) = store_with_registry();
2217        let agents_root = tmp.path().join("agents");
2218        let mut inst = make_named_inst("inst-hide", "ToBeHidden", &agents_root);
2219        inst.identity_id = "id-still-valid".to_string();
2220        inst.display_hidden = true; // bound block stays alive
2221        store.instance_create(&inst).unwrap();
2222
2223        let mut block = crate::backend::obj::Block {
2224            oid: "block-hidden-agent".to_string(),
2225            parentoref: String::new(),
2226            version: 0,
2227            runtimeopts: None,
2228            stickers: None,
2229            meta: {
2230                let mut m = crate::backend::obj::MetaMapType::new();
2231                m.insert("agentId".to_string(), serde_json::json!("def-mirror"));
2232                m
2233            },
2234            subblockids: None,
2235        };
2236        store.insert(&mut block).unwrap();
2237
2238        let got = store
2239            .instance_get_active_for_block("block-hidden-agent")
2240            .unwrap()
2241            .expect("hidden agent must still resolve for existing pane");
2242        assert_eq!(got.identity_id, "id-still-valid");
2243    }
2244
2245    #[test]
2246    fn instance_get_active_for_block_phase_3b4_legacy_template_block_falls_back_to_instances() {
2247        // Codex P2 on PR #1114 round 2: panes launched from a seeded
2248        // template BEFORE `agentInstanceId` stamping was wired only
2249        // carry `agentId = <template id>`. The db_agents path filters
2250        // templates out (is_template = 0), so without a fallback
2251        // those panes would silently fall back to ambient creds.
2252        // Recovery: legacy `db_agent_instances` lookup by block_id.
2253        let (tmp, store, _reg) = store_with_registry();
2254        let agents_root = tmp.path().join("agents");
2255
2256        // Seeded template + an instance launched off it (the way old
2257        // launches before agentInstanceId stamping would have done).
2258        let mut tpl = sample_agent("tpl-legacy", "tpl-legacy");
2259        tpl.is_seeded = 1;
2260        store.agent_def_insert(&mut tpl).unwrap();
2261        let mut inst = make_named_inst("inst-legacy-tpl", "OldTplLaunch", &agents_root);
2262        inst.definition_id = "tpl-legacy".to_string();
2263        inst.identity_id = "id-legacy-tpl".to_string();
2264        inst.block_id = "block-old-tpl".to_string();
2265        store.instance_create(&inst).unwrap();
2266
2267        // Block ONLY records the template id (no agentInstanceId).
2268        let mut block = crate::backend::obj::Block {
2269            oid: "block-old-tpl".to_string(),
2270            parentoref: String::new(),
2271            version: 0,
2272            runtimeopts: None,
2273            stickers: None,
2274            meta: {
2275                let mut m = crate::backend::obj::MetaMapType::new();
2276                m.insert("agentId".to_string(), serde_json::json!("tpl-legacy"));
2277                m
2278            },
2279            subblockids: None,
2280        };
2281        store.insert(&mut block).unwrap();
2282
2283        let got = store
2284            .instance_get_active_for_block("block-old-tpl")
2285            .unwrap()
2286            .expect("legacy template block must resolve via db_agent_instances fallback");
2287        assert_eq!(got.identity_id, "id-legacy-tpl");
2288    }
2289
2290    #[test]
2291    fn instance_get_active_for_block_phase_3b4_honors_legacy_agent_id_meta_key() {
2292        // Older blocks may still carry `agent:id` instead of `agentId`.
2293        // Both keys should resolve.
2294        let (tmp, store, _reg) = store_with_registry();
2295        let agents_root = tmp.path().join("agents");
2296        let mut inst = make_named_inst("inst-legacy", "Maks", &agents_root);
2297        store.instance_create(&inst).unwrap();
2298
2299        let mut block = crate::backend::obj::Block {
2300            oid: "block-legacy".to_string(),
2301            parentoref: String::new(),
2302            version: 0,
2303            runtimeopts: None,
2304            stickers: None,
2305            meta: {
2306                let mut m = crate::backend::obj::MetaMapType::new();
2307                m.insert("agent:id".to_string(), serde_json::json!("def-mirror"));
2308                m
2309            },
2310            subblockids: None,
2311        };
2312        store.insert(&mut block).unwrap();
2313
2314        let got = store
2315            .instance_get_active_for_block("block-legacy")
2316            .unwrap()
2317            .expect("legacy agent:id meta key must resolve");
2318        assert_eq!(got.id, "def-mirror");
2319    }
2320
2321    #[test]
2322    fn instance_list_phase_3b3a_reads_from_db_agents_no_status_filter() {
2323        // Phase 3b.3a: with no status filter, `instance_list` reads
2324        // the consolidated `db_agents` table — continuation chains
2325        // pre-collapse to one row per logical agent, hidden rows
2326        // excluded.
2327        let (tmp, store, _reg) = store_with_registry();
2328        let agents_root = tmp.path().join("agents");
2329
2330        // Two distinct named agents on the same folded def. Each
2331        // should surface ONCE (one db_agents row each).
2332        let a = make_named_inst("inst-a", "Maks", &agents_root);
2333        store.instance_create(&a).unwrap();
2334        let b = {
2335            // Different def so the rows don't collide on def.id.
2336            let mut def2 = sample_agent("def-mirror-2", "mirror-2");
2337            store.agent_def_insert(&mut def2).unwrap();
2338            let mut x = make_named_inst("inst-b", "DSad", &agents_root);
2339            x.definition_id = "def-mirror-2".to_string();
2340            x
2341        };
2342        store.instance_create(&b).unwrap();
2343        // Continuation of "Maks" — should NOT add a row.
2344        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2345        cont.parent_instance_id = "inst-a".to_string();
2346        store.instance_create(&cont).unwrap();
2347
2348        let rows = store.instance_list(None, None).unwrap();
2349        let names: Vec<&str> = rows.iter().map(|r| r.instance_name.as_str()).collect();
2350        assert_eq!(rows.len(), 2);
2351        assert!(names.contains(&"Maks"));
2352        assert!(names.contains(&"DSad"));
2353        // Transient fields default — see doc comment on instance_list.
2354        for row in &rows {
2355            assert_eq!(row.status, "");
2356            assert_eq!(row.block_id, "");
2357            assert_eq!(row.session_id, "");
2358        }
2359    }
2360
2361    #[test]
2362    fn instance_list_phase_3b3a_filters_by_definition_lineage() {
2363        // The legacy `definition_id` filter matches against
2364        // `parent_template_id` in the consolidated view. For folded
2365        // user-clones (where the db_agents id IS the def.id), also
2366        // match the row's own id — both should resolve the def.
2367        let (tmp, store, _reg) = store_with_registry();
2368        let agents_root = tmp.path().join("agents");
2369
2370        let a = make_named_inst("inst-a", "Maks", &agents_root);
2371        store.instance_create(&a).unwrap();
2372        let mut def2 = sample_agent("def-other", "other");
2373        store.agent_def_insert(&mut def2).unwrap();
2374        let mut b = make_named_inst("inst-b", "Other", &agents_root);
2375        b.definition_id = "def-other".to_string();
2376        store.instance_create(&b).unwrap();
2377
2378        // Filter by def-mirror — only the Maks row matches.
2379        let filtered = store.instance_list(Some("def-mirror"), None).unwrap();
2380        assert_eq!(filtered.len(), 1);
2381        assert_eq!(filtered[0].instance_name, "Maks");
2382
2383        let filtered = store.instance_list(Some("def-other"), None).unwrap();
2384        assert_eq!(filtered.len(), 1);
2385        assert_eq!(filtered[0].instance_name, "Other");
2386    }
2387
2388    #[test]
2389    fn instance_list_phase_3b3a_definition_id_filter_matches_agent_id_only() {
2390        // Codex P2 on PR #1111: the legacy `definition_id` filter
2391        // conflated "agent identity" with "parent template" because of
2392        // the old schema split. In the consolidated model the filter
2393        // matches the agent's own id only — templates aren't agents
2394        // (they have `is_template = 1`, which is excluded by the
2395        // outer WHERE) and user-clones of a template are SEPARATE
2396        // agents with their own id, NOT children of the template.
2397        let (tmp, store, _reg) = store_with_registry();
2398        let agents_root = tmp.path().join("agents");
2399
2400        // A seeded template + a user-clone derived from it + a
2401        // template-instance launched directly off the template.
2402        let mut tpl = sample_agent("tpl-coder", "tpl-coder");
2403        tpl.is_seeded = 1;
2404        store.agent_def_insert(&mut tpl).unwrap();
2405        let mut clone = sample_agent("clone-of-tpl", "clone-of-tpl");
2406        clone.parent_id = "tpl-coder".to_string();
2407        store.agent_def_insert(&mut clone).unwrap();
2408        let mut tpl_inst = make_named_inst("inst-direct", "DirectTpl", &agents_root);
2409        tpl_inst.definition_id = "tpl-coder".to_string();
2410        store.instance_create(&tpl_inst).unwrap();
2411
2412        // Filter by the template's id returns empty — templates are
2413        // not agents, and we no longer follow the parent_template_id
2414        // backlink (which would over-match).
2415        let by_tpl = store.instance_list(Some("tpl-coder"), None).unwrap();
2416        assert!(by_tpl.is_empty(), "template id should not surface any agent");
2417
2418        // Filter by the user-clone's id returns just the clone row.
2419        let by_clone = store.instance_list(Some("clone-of-tpl"), None).unwrap();
2420        assert_eq!(by_clone.len(), 1);
2421        assert_eq!(by_clone[0].id, "clone-of-tpl");
2422
2423        // Filter by the template-instance id returns just that inst.
2424        let by_inst = store.instance_list(Some("inst-direct"), None).unwrap();
2425        assert_eq!(by_inst.len(), 1);
2426        assert_eq!(by_inst[0].id, "inst-direct");
2427        assert_eq!(by_inst[0].instance_name, "DirectTpl");
2428
2429        // Filter by non-existent id returns empty.
2430        let none = store.instance_list(Some("does-not-exist"), None).unwrap();
2431        assert!(none.is_empty());
2432    }
2433
2434    #[test]
2435    fn instance_list_phase_3b3a_orders_by_updated_at_for_continuations() {
2436        // Reagent P2 on PR #1111 round 2: continuations bump
2437        // `db_agents.updated_at` but leave `created_at` at the chain
2438        // head's original timestamp. `ORDER BY created_at` would rank
2439        // a fresh agent ahead of an actively-continued older one;
2440        // `ORDER BY updated_at` preserves "most recent activity
2441        // first".
2442        let (tmp, store, _reg) = store_with_registry();
2443        let agents_root = tmp.path().join("agents");
2444
2445        // Older agent, first.
2446        let older_head = make_named_inst("inst-old-head", "Older", &agents_root);
2447        store.instance_create(&older_head).unwrap();
2448
2449        // Newer agent (different def so different db_agents row).
2450        let mut def2 = sample_agent("def-newer", "newer");
2451        store.agent_def_insert(&mut def2).unwrap();
2452        let mut newer = make_named_inst("inst-newer", "Newer", &agents_root);
2453        newer.definition_id = "def-newer".to_string();
2454        store.instance_create(&newer).unwrap();
2455
2456        // Continue the OLDER agent — bumps its updated_at past
2457        // the newer agent's.
2458        let mut cont = make_named_inst("inst-old-cont", "Older", &agents_root);
2459        cont.parent_instance_id = "inst-old-head".to_string();
2460        store.instance_create(&cont).unwrap();
2461
2462        let rows = store.instance_list(None, None).unwrap();
2463        assert_eq!(rows.len(), 2);
2464        // Older's continuation made it most recent → it ranks first.
2465        assert_eq!(rows[0].instance_name, "Older");
2466        assert_eq!(rows[1].instance_name, "Newer");
2467    }
2468
2469    #[test]
2470    fn instance_list_phase_3b3a_definition_id_projects_row_id_for_user_clones_with_template_lineage() {
2471        // Reagent P2 on PR #1111 round 2: user-clones derived from a
2472        // template have `parent_template_id` SET (lineage), but their
2473        // legacy `definition_id` was the clone's OWN def id, not the
2474        // template's. The projection must yield the row's id, not
2475        // walk parent_template_id.
2476        let (tmp, store, _reg) = store_with_registry();
2477        let agents_root = tmp.path().join("agents");
2478
2479        // A user-clone def whose parent_id points at a (non-existent)
2480        // template — what matters is that parent_template_id ends up
2481        // SET on the db_agents row.
2482        let mut clone = sample_agent("clone-from-tpl", "clone-from-tpl");
2483        clone.parent_id = "tpl-some-template".to_string();
2484        store.agent_def_insert(&mut clone).unwrap();
2485        let mut launch = make_named_inst("inst-1", "ClonedAgent", &agents_root);
2486        launch.definition_id = "clone-from-tpl".to_string();
2487        store.instance_create(&launch).unwrap();
2488
2489        let rows = store.instance_list(None, None).unwrap();
2490        let row = rows.iter().find(|r| r.instance_name == "ClonedAgent").unwrap();
2491        // definition_id is the CLONE's id, not the template id —
2492        // even though parent_template_id on the underlying row is set.
2493        assert_eq!(row.definition_id, "clone-from-tpl");
2494        assert_eq!(row.id, "clone-from-tpl");
2495    }
2496
2497    #[test]
2498    fn instance_list_phase_3b3a_excludes_hidden() {
2499        let (tmp, store, _reg) = store_with_registry();
2500        let agents_root = tmp.path().join("agents");
2501        let mut inst = make_named_inst("inst-h", "Ghost", &agents_root);
2502        inst.display_hidden = true;
2503        store.instance_create(&inst).unwrap();
2504        let rows = store.instance_list(None, None).unwrap();
2505        assert!(rows.is_empty(), "hidden rows must not surface");
2506    }
2507
2508    #[test]
2509    fn instance_list_phase_3b3b_status_filter_falls_back_to_legacy() {
2510        // Status filter implies the caller needs transient runtime
2511        // state. Until the updateagentinstance fetch+merge pattern is
2512        // refactored, that read must come from db_agent_instances.
2513        // Verify the legacy path is exercised end-to-end (rows include
2514        // status field populated from the raw instance row).
2515        let (tmp, store, _reg) = store_with_registry();
2516        let agents_root = tmp.path().join("agents");
2517        let mut a = make_named_inst("inst-a", "Maks", &agents_root);
2518        a.status = "running".to_string();
2519        store.instance_create(&a).unwrap();
2520
2521        let running = store.instance_list(None, Some("running")).unwrap();
2522        assert_eq!(running.len(), 1);
2523        assert_eq!(running[0].status, "running");
2524        assert_eq!(running[0].id, "inst-a"); // raw inst id, NOT the folded def.id
2525
2526        let stopped = store.instance_list(None, Some("stopped")).unwrap();
2527        assert!(stopped.is_empty());
2528    }
2529
2530    #[test]
2531    fn instance_list_named_picker_mode_keeps_distinct_agents_separate() {
2532        // Two unrelated chains (different agents, different names)
2533        // remain as two rows. The dedup is per-chain, not per-name.
2534        let (tmp, store, _reg) = store_with_registry();
2535        let agents_root = tmp.path().join("agents");
2536
2537        let mut head_a = make_named_inst("a-head", "AgentA", &agents_root);
2538        head_a.started_at = 100;
2539        store.instance_create(&head_a).unwrap();
2540        let mut cont_a = make_named_inst("a-cont", "AgentA", &agents_root);
2541        cont_a.parent_instance_id = "a-head".to_string();
2542        cont_a.started_at = 150;
2543        store.instance_create(&cont_a).unwrap();
2544
2545        let mut head_b = make_named_inst("b-head", "AgentB", &agents_root);
2546        head_b.started_at = 200;
2547        store.instance_create(&head_b).unwrap();
2548
2549        let rows = store.instance_list_named(10, None, None, true).unwrap();
2550        assert_eq!(rows.len(), 2);
2551        // MRU order: b-head (200) before a-cont (150).
2552        assert_eq!(rows[0].id, "b-head");
2553        assert_eq!(rows[1].id, "a-cont");
2554    }
2555
2556    #[test]
2557    fn instance_list_named_picker_mode_orphan_continuation_surfaces() {
2558        // Regression for codex P2 on PR #1096 bbe897cc: when a chain
2559        // head is hard-deleted via `deleteagentinstance` (no FK
2560        // cascade on `parent_instance_id`), descendant continuation
2561        // rows are orphaned — `parent_instance_id` points at an id
2562        // that no longer exists.
2563        //
2564        // The recursive CTE anchor must seed from BOTH (a) real
2565        // heads (`parent_instance_id = ''`) and (b) orphans (parent
2566        // doesn't exist in the table). Without the orphan anchor,
2567        // the recursive walk can't reach them and they disappear
2568        // from My Agents — even though they're recoverable sessions
2569        // the previous (buggy) query surfaced.
2570        let (tmp, store, _reg) = store_with_registry();
2571        let agents_root = tmp.path().join("agents");
2572
2573        // Seed a chain: head + 2 continuations.
2574        let mut head = make_named_inst("inst-deleted-head", "Claude", &agents_root);
2575        head.started_at = 100;
2576        store.instance_create(&head).unwrap();
2577
2578        let mut cont1 = make_named_inst("inst-orphan-cont1", "Claude", &agents_root);
2579        cont1.parent_instance_id = "inst-deleted-head".to_string();
2580        cont1.started_at = 200;
2581        store.instance_create(&cont1).unwrap();
2582
2583        let mut cont2 = make_named_inst("inst-orphan-cont2", "Claude", &agents_root);
2584        cont2.parent_instance_id = "inst-orphan-cont1".to_string();
2585        cont2.started_at = 300;
2586        store.instance_create(&cont2).unwrap();
2587
2588        // Hard-delete the head — no cascade, so cont1 + cont2 are
2589        // now orphaned (cont1.parent_instance_id points at the
2590        // deleted head; cont2 still has a valid parent).
2591        store.instance_delete("inst-deleted-head").unwrap();
2592
2593        let rows = store.instance_list_named(10, None, None, true).unwrap();
2594        // The orphan chain (cont1 → cont2) must surface as ONE row:
2595        // cont1 becomes a root (its parent is gone); cont2 chains
2596        // off cont1. Newest in chain (cont2) wins.
2597        assert_eq!(
2598            rows.len(),
2599            1,
2600            "orphan chain must remain reachable after head deletion"
2601        );
2602        assert_eq!(rows[0].id, "inst-orphan-cont2");
2603    }
2604
2605    #[test]
2606    fn instance_list_named_picker_mode_forget_suppresses_whole_chain() {
2607        // Regression for codex P2 on PR #1096: when the user clicks
2608        // "Forget" on a continuation row that's currently the picker's
2609        // surfaced entry, `hidenamedagent` flips `display_hidden=1`
2610        // only on that one row. If the dedup query filtered hidden
2611        // BEFORE ranking, the next-newest visible row in the same
2612        // chain would inherit `rn = 1` and the "forgotten" agent
2613        // would immediately reappear — making forget a no-op.
2614        //
2615        // Correct behavior: filter hidden AFTER ranking. When the
2616        // surfaced row is hidden, the entire chain disappears from
2617        // the picker until the user explicitly unhides one.
2618        let (tmp, store, _reg) = store_with_registry();
2619        let agents_root = tmp.path().join("agents");
2620
2621        let mut head = make_named_inst("inst-head", "Claude", &agents_root);
2622        head.started_at = 100;
2623        store.instance_create(&head).unwrap();
2624
2625        let mut cont1 = make_named_inst("inst-cont1", "Claude", &agents_root);
2626        cont1.parent_instance_id = "inst-head".to_string();
2627        cont1.started_at = 200;
2628        store.instance_create(&cont1).unwrap();
2629
2630        let mut cont2 = make_named_inst("inst-cont2", "Claude", &agents_root);
2631        cont2.parent_instance_id = "inst-cont1".to_string();
2632        cont2.started_at = 300;
2633        store.instance_create(&cont2).unwrap();
2634
2635        // Before forget: chain surfaces as cont2 (newest).
2636        let before = store.instance_list_named(10, None, None, true).unwrap();
2637        assert_eq!(before.len(), 1);
2638        assert_eq!(before[0].id, "inst-cont2");
2639
2640        // User clicks "Forget" on the surfaced row.
2641        store.instance_set_hidden("inst-cont2", true).unwrap();
2642
2643        // After forget: the whole chain must stay forgotten — older
2644        // visible rows in the chain (head, cont1) must NOT bubble up.
2645        let after = store.instance_list_named(10, None, None, true).unwrap();
2646        assert_eq!(
2647            after.len(),
2648            0,
2649            "hiding the surfaced row must suppress the entire chain — \
2650             older visible siblings must NOT be promoted to rn=1"
2651        );
2652    }
2653
2654    #[test]
2655    fn instance_list_named_picker_mode_skips_hidden_chains() {
2656        // A hidden continuation should not win the ranking; its
2657        // sibling (if any) does. If the entire chain is hidden,
2658        // it disappears entirely.
2659        let (tmp, store, _reg) = store_with_registry();
2660        let agents_root = tmp.path().join("agents");
2661
2662        // Chain 1: head + cont, both visible.
2663        let mut head = make_named_inst("v-head", "Visible", &agents_root);
2664        head.started_at = 100;
2665        store.instance_create(&head).unwrap();
2666        let mut cont = make_named_inst("v-cont", "Visible", &agents_root);
2667        cont.parent_instance_id = "v-head".to_string();
2668        cont.started_at = 200;
2669        store.instance_create(&cont).unwrap();
2670
2671        // Chain 2: head + cont, both hidden.
2672        let mut hidden_head = make_named_inst("h-head", "Hidden", &agents_root);
2673        hidden_head.started_at = 50;
2674        hidden_head.display_hidden = true;
2675        store.instance_create(&hidden_head).unwrap();
2676        let mut hidden_cont = make_named_inst("h-cont", "Hidden", &agents_root);
2677        hidden_cont.parent_instance_id = "h-head".to_string();
2678        hidden_cont.started_at = 60;
2679        hidden_cont.display_hidden = true;
2680        store.instance_create(&hidden_cont).unwrap();
2681
2682        let rows = store.instance_list_named(10, None, None, true).unwrap();
2683        assert_eq!(rows.len(), 1);
2684        assert_eq!(rows[0].id, "v-cont");
2685    }
2686
2687    #[test]
2688    fn instance_list_named_picker_mode_identity_filter_in_ranking() {
2689        // Codex P2 #3 on PR #1096 0c4c8c46: identity_id filter must
2690        // participate in the dedup ranking. If we returned the newest
2691        // row in a chain and then filtered identity, a chain whose
2692        // newest row used a different identity would disappear from
2693        // the picker — even if an older row in the chain matched the
2694        // requested identity. Push the filter INTO the CTE so the
2695        // newest IDENTITY-MATCHING row per chain wins.
2696        let (tmp, store, _reg) = store_with_registry();
2697        let agents_root = tmp.path().join("agents");
2698
2699        // Chain: head with identity-a, cont with identity-b, then
2700        // another cont with identity-a (the user switched back).
2701        let mut head = make_named_inst("inst-head", "Claude", &agents_root);
2702        head.identity_id = "identity-a".to_string();
2703        head.started_at = 100;
2704        store.instance_create(&head).unwrap();
2705
2706        let mut cont_b = make_named_inst("inst-cont-b", "Claude", &agents_root);
2707        cont_b.parent_instance_id = "inst-head".to_string();
2708        cont_b.identity_id = "identity-b".to_string();
2709        cont_b.started_at = 200;
2710        store.instance_create(&cont_b).unwrap();
2711
2712        let mut cont_a2 = make_named_inst("inst-cont-a2", "Claude", &agents_root);
2713        cont_a2.parent_instance_id = "inst-cont-b".to_string();
2714        cont_a2.identity_id = "identity-a".to_string();
2715        cont_a2.started_at = 300;
2716        store.instance_create(&cont_a2).unwrap();
2717
2718        // Filter by identity-a → newest identity-a row wins (cont-a2).
2719        let rows_a = store
2720            .instance_list_named(10, None, Some("identity-a"), true)
2721            .unwrap();
2722        assert_eq!(rows_a.len(), 1);
2723        assert_eq!(rows_a[0].id, "inst-cont-a2");
2724
2725        // Filter by identity-b → only cont-b matches.
2726        let rows_b = store
2727            .instance_list_named(10, None, Some("identity-b"), true)
2728            .unwrap();
2729        assert_eq!(rows_b.len(), 1);
2730        assert_eq!(rows_b[0].id, "inst-cont-b");
2731
2732        // No filter → newest in chain wins (cont-a2, started_at=300).
2733        let rows_all = store.instance_list_named(10, None, None, true).unwrap();
2734        assert_eq!(rows_all.len(), 1);
2735        assert_eq!(rows_all[0].id, "inst-cont-a2");
2736    }
2737
2738    #[test]
2739    fn instance_list_named_picker_mode_identity_filter_recovers_older_match() {
2740        // Concrete repro for the bug codex described: chain where the
2741        // newest row uses identity-b but only older rows match the
2742        // requested identity-a. Without the in-ranking filter, the
2743        // chain would disappear when filtering by identity-a (newest
2744        // row is identity-b, gets ranked first, then post-filter
2745        // drops it). With the in-ranking filter, identity-a's older
2746        // row survives because it's the only candidate.
2747        let (tmp, store, _reg) = store_with_registry();
2748        let agents_root = tmp.path().join("agents");
2749
2750        let mut head = make_named_inst("inst-head", "Claude", &agents_root);
2751        head.identity_id = "identity-a".to_string();
2752        head.started_at = 100;
2753        store.instance_create(&head).unwrap();
2754
2755        let mut cont_newer_b = make_named_inst("inst-cont-newer-b", "Claude", &agents_root);
2756        cont_newer_b.parent_instance_id = "inst-head".to_string();
2757        cont_newer_b.identity_id = "identity-b".to_string();
2758        cont_newer_b.started_at = 200;
2759        store.instance_create(&cont_newer_b).unwrap();
2760
2761        let rows = store
2762            .instance_list_named(10, None, Some("identity-a"), true)
2763            .unwrap();
2764        assert_eq!(
2765            rows.len(),
2766            1,
2767            "older identity-a row must survive even though the newest row uses identity-b"
2768        );
2769        assert_eq!(rows[0].id, "inst-head");
2770    }
2771
2772    #[test]
2773    fn instance_list_named_dropdown_mode_excludes_continuations() {
2774        // Launch-modal "Continue agent" dropdown / `listnamedagents`
2775        // registry-enrichment path: `include_continuations = false`.
2776        // Symmetric with `registry_upsert_if_named`'s mirror filter —
2777        // a chain shows up as ONE entry (the head), not N entries
2778        // for every resume. Codex P1 on PR #1016 first cut: when the
2779        // enrichment path lost this filter, continuation rows could
2780        // displace registry-head rows under the `limit` truncation
2781        // and miss the merge-by-id enrichment.
2782        let (tmp, store, _reg) = store_with_registry();
2783        let agents_root = tmp.path().join("agents");
2784
2785        let mut head = make_named_inst("inst-head", "Maks", &agents_root);
2786        head.started_at = 100;
2787        store.instance_create(&head).unwrap();
2788        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2789        cont.parent_instance_id = "inst-head".to_string();
2790        cont.started_at = 200;
2791        store.instance_create(&cont).unwrap();
2792
2793        let dropdown_rows = store.instance_list_named(10, None, None, false).unwrap();
2794        assert_eq!(
2795            dropdown_rows.len(),
2796            1,
2797            "legacy dropdown mode must drop continuation rows"
2798        );
2799        assert_eq!(dropdown_rows[0].id, "inst-head");
2800
2801        // Definition-scoped dropdown mode — head only.
2802        let scoped_dropdown = store
2803            .instance_list_named(10, Some("def-mirror"), None, false)
2804            .unwrap();
2805        assert_eq!(scoped_dropdown.len(), 1);
2806        assert_eq!(scoped_dropdown[0].id, "inst-head");
2807    }
2808
2809    #[test]
2810    fn instance_update_does_not_resurrect_hidden_row() {
2811        // Sequence: create (active) → set_hidden(true) → update.
2812        // The update must NOT move the file from retired/ back to active.
2813        let (tmp, store, reg) = store_with_registry();
2814        let agents_root = tmp.path().join("agents");
2815        let inst = make_named_inst("inst-resurrect", "demoR", &agents_root);
2816        store.instance_create(&inst).unwrap();
2817        assert_eq!(reg.list_active().unwrap().len(), 1);
2818
2819        store.instance_set_hidden("inst-resurrect", true).unwrap();
2820        assert!(reg.list_active().unwrap().is_empty(),
2821            "after set_hidden(true), record must be in retired/");
2822        assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
2823
2824        // SQLite still has the row (display_hidden=1). instance_update
2825        // would refresh it — the mirror must NOT re-add to active.
2826        let mut updated = inst.clone();
2827        updated.status = "stopped".to_string();
2828        updated.ended_at = 9999;
2829        store.instance_update(&updated).unwrap();
2830
2831        assert!(reg.list_active().unwrap().is_empty(),
2832            "instance_update on a hidden row must NOT resurrect it");
2833        assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
2834    }
2835
2836    #[test]
2837    fn instance_create_with_display_hidden_writes_retired() {
2838        let (tmp, store, reg) = store_with_registry();
2839        let agents_root = tmp.path().join("agents");
2840        let mut inst = make_named_inst("inst-bornhidden", "demoH", &agents_root);
2841        inst.display_hidden = true;
2842        store.instance_create(&inst).unwrap();
2843        assert!(reg.list_active().unwrap().is_empty());
2844        assert!(reg.root().join("retired").join("inst-bornhidden.json").exists());
2845    }
2846
2847    #[test]
2848    fn instance_update_toggling_hidden_off_unretires() {
2849        // Sequence: create → set_hidden(true) → set_hidden(false) →
2850        // update. After the toggle off, the file should be in active.
2851        let (tmp, store, reg) = store_with_registry();
2852        let agents_root = tmp.path().join("agents");
2853        let inst = make_named_inst("inst-toggle", "demoT", &agents_root);
2854        store.instance_create(&inst).unwrap();
2855        store.instance_set_hidden("inst-toggle", true).unwrap();
2856        store.instance_set_hidden("inst-toggle", false).unwrap();
2857        assert_eq!(reg.list_active().unwrap().len(), 1);
2858
2859        // A subsequent update should preserve active state.
2860        let mut updated = inst.clone();
2861        updated.status = "paused".to_string();
2862        store.instance_update(&updated).unwrap();
2863        assert_eq!(reg.list_active().unwrap().len(), 1);
2864        assert!(!reg.root().join("retired").join("inst-toggle.json").exists(),
2865            "no orphan retired file alongside active");
2866    }
2867
2868    #[test]
2869    fn instance_set_hidden_acts_on_registry_only_row() {
2870        // Cross-version case: a registry record exists (e.g. migrated
2871        // from another version's SQLite) but the current version's
2872        // SQLite has no matching row. `instance_set_hidden` must still
2873        // flip the registry file and report success.
2874        let (tmp, store, reg) = store_with_registry();
2875        // Seed a registry record directly — no SQLite row.
2876        let agents_root = tmp.path().join("agents");
2877        std::fs::create_dir_all(&agents_root).unwrap();
2878        let wd = agents_root.join("cross-ver");
2879        std::fs::create_dir_all(&wd).unwrap();
2880        reg.upsert(&crate::registry::NamedAgentRecord {
2881            schema_version: crate::registry::MAX_SUPPORTED_SCHEMA,
2882            data: crate::registry::NamedAgentRecordV1 {
2883                instance_id: "inst-crossver".to_string(),
2884                instance_name: "crossver".to_string(),
2885                definition_id: "claude-code".to_string(),
2886                identity_id: None,
2887                memory_id: None,
2888                session_id: None,
2889                working_dir: "cross-ver".to_string(),
2890                source_agents_base: None,
2891                created_at_ms: 100,
2892                last_launched_at_ms: 100,
2893                created_by_version: "0.33.821".to_string(),
2894                last_launched_by_version: "0.33.821".to_string(),
2895            },
2896        })
2897        .unwrap();
2898        assert_eq!(reg.list_active().unwrap().len(), 1);
2899        assert!(store.instance_get("inst-crossver").unwrap().is_none(),
2900            "precondition: no SQLite row for cross-version agent");
2901
2902        let result = store.instance_set_hidden("inst-crossver", true).unwrap();
2903        assert!(result, "must report success even when only registry was affected");
2904        assert!(reg.list_active().unwrap().is_empty(),
2905            "registry record must be retired");
2906        assert!(reg.root().join("retired").join("inst-crossver.json").exists());
2907    }
2908
2909    #[test]
2910    fn agent_def_delete_cascade_removes_registry_files() {
2911        let (tmp, store, reg) = store_with_registry();
2912        let agents_root = tmp.path().join("agents");
2913        let inst_a = make_named_inst("inst-cascade-a", "demoA", &agents_root);
2914        let inst_b = make_named_inst("inst-cascade-b", "demoB", &agents_root);
2915        store.instance_create(&inst_a).unwrap();
2916        store.instance_create(&inst_b).unwrap();
2917        assert_eq!(reg.list_active().unwrap().len(), 2);
2918
2919        // Delete the agent definition — SQLite FK cascades both instance
2920        // rows; the mirror must also drop both registry files.
2921        store.agent_def_delete("def-mirror").unwrap();
2922        assert!(reg.list_active().unwrap().is_empty(),
2923            "agent_def_delete cascade must remove all child instance registry files");
2924    }
2925
2926    // ----------------------------------------------------------------
2927    // Phase 3a — db_agents dual-write coverage
2928    // ----------------------------------------------------------------
2929
2930    fn count_agents(store: &Store, where_clause: &str) -> i64 {
2931        let conn = store.conn.lock().unwrap();
2932        let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
2933        conn.query_row(&sql, [], |row| row.get(0)).unwrap()
2934    }
2935
2936    fn read_agent_field(store: &Store, id: &str, field: &str) -> Option<String> {
2937        let conn = store.conn.lock().unwrap();
2938        let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
2939        let mut stmt = conn.prepare(&sql).unwrap();
2940        let r = stmt.query_row(params![id], |row| row.get::<_, String>(0));
2941        match r {
2942            Ok(s) => Some(s),
2943            Err(rusqlite::Error::QueryReturnedNoRows) => None,
2944            Err(e) => panic!("query failed: {e}"),
2945        }
2946    }
2947
2948    fn read_agent_int(store: &Store, id: &str, field: &str) -> Option<i64> {
2949        let conn = store.conn.lock().unwrap();
2950        let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
2951        let mut stmt = conn.prepare(&sql).unwrap();
2952        let r = stmt.query_row(params![id], |row| row.get::<_, i64>(0));
2953        match r {
2954            Ok(v) => Some(v),
2955            Err(rusqlite::Error::QueryReturnedNoRows) => None,
2956            Err(e) => panic!("query failed: {e}"),
2957        }
2958    }
2959
2960    #[test]
2961    fn dual_write_agent_def_insert_seeded_creates_template_row() {
2962        let store = make_store();
2963        let mut def = AgentDefinition {
2964            id: "tpl-dw-seeded".to_string(),
2965            slug: String::new(),
2966            name: "Coder".to_string(),
2967            icon: "✦".to_string(),
2968            provider: "claude".to_string(),
2969            description: "desc".to_string(),
2970            working_directory: String::new(),
2971            shell: "bash".to_string(),
2972            provider_flags: String::new(),
2973            auto_start: 0,
2974            restart_on_crash: 0,
2975            idle_timeout_minutes: 0,
2976            created_at: 1000,
2977            agent_type: "standalone".to_string(),
2978            environment: String::new(),
2979            agent_bus_id: String::new(),
2980            is_seeded: 1,
2981            accounts: String::new(),
2982            parent_id: String::new(),
2983            branch_label: String::new(),
2984            updated_at: 1000,
2985            user_hidden: 0,
2986            container_image: String::new(),
2987            container_volumes: "[]".to_string(),
2988            container_name: String::new(),
2989        };
2990        store.agent_def_insert(&mut def).unwrap();
2991        // db_agents row exists, projected as template.
2992        assert_eq!(read_agent_int(&store, "tpl-dw-seeded", "is_template"), Some(1));
2993        assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "parent_template_id"), Some(String::new()));
2994        assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "name"), Some("Coder".to_string()));
2995        assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "provider"), Some("claude".to_string()));
2996    }
2997
2998    #[test]
2999    fn dual_write_agent_def_insert_user_clone_carries_parent() {
3000        let store = make_store();
3001        // Seed the template first so the FK exists in the old table.
3002        let mut tpl = AgentDefinition {
3003            id: "tpl-parent".to_string(),
3004            slug: String::new(),
3005            name: "Coder".to_string(),
3006            icon: "✦".to_string(),
3007            provider: "claude".to_string(),
3008            description: String::new(),
3009            working_directory: String::new(),
3010            shell: "bash".to_string(),
3011            provider_flags: String::new(),
3012            auto_start: 0,
3013            restart_on_crash: 0,
3014            idle_timeout_minutes: 0,
3015            created_at: 1000,
3016            agent_type: "standalone".to_string(),
3017            environment: String::new(),
3018            agent_bus_id: String::new(),
3019            is_seeded: 1,
3020            accounts: String::new(),
3021            parent_id: String::new(),
3022            branch_label: String::new(),
3023            updated_at: 1000,
3024            user_hidden: 0,
3025            container_image: String::new(),
3026            container_volumes: "[]".to_string(),
3027            container_name: String::new(),
3028        };
3029        store.agent_def_insert(&mut tpl).unwrap();
3030        // User-cloned def has is_seeded=0 + parent_id pointing at template.
3031        let mut user_def = tpl.clone();
3032        user_def.id = "def-user".to_string();
3033        user_def.slug = String::new();
3034        user_def.is_seeded = 0;
3035        user_def.parent_id = "tpl-parent".to_string();
3036        store.agent_def_insert(&mut user_def).unwrap();
3037        assert_eq!(read_agent_int(&store, "def-user", "is_template"), Some(0));
3038        assert_eq!(
3039            read_agent_field(&store, "def-user", "parent_template_id"),
3040            Some("tpl-parent".to_string())
3041        );
3042    }
3043
3044    #[test]
3045    fn dual_write_agent_def_update_refreshes_name_in_db_agents() {
3046        let store = make_store();
3047        let mut def = AgentDefinition {
3048            id: "tpl-update".to_string(),
3049            slug: String::new(),
3050            name: "Old Name".to_string(),
3051            icon: "✦".to_string(),
3052            provider: "claude".to_string(),
3053            description: String::new(),
3054            working_directory: String::new(),
3055            shell: "bash".to_string(),
3056            provider_flags: String::new(),
3057            auto_start: 0,
3058            restart_on_crash: 0,
3059            idle_timeout_minutes: 0,
3060            created_at: 1000,
3061            agent_type: "standalone".to_string(),
3062            environment: String::new(),
3063            agent_bus_id: String::new(),
3064            is_seeded: 1,
3065            accounts: String::new(),
3066            parent_id: String::new(),
3067            branch_label: String::new(),
3068            updated_at: 1000,
3069            user_hidden: 0,
3070            container_image: String::new(),
3071            container_volumes: "[]".to_string(),
3072            container_name: String::new(),
3073        };
3074        store.agent_def_insert(&mut def).unwrap();
3075        def.name = "New Name".to_string();
3076        assert!(store.agent_def_update(&mut def).unwrap());
3077        assert_eq!(
3078            read_agent_field(&store, "tpl-update", "name"),
3079            Some("New Name".to_string())
3080        );
3081    }
3082
3083    #[test]
3084    fn dual_write_agent_def_delete_removes_db_agents_row() {
3085        let store = make_store();
3086        let mut def = AgentDefinition {
3087            id: "tpl-del".to_string(),
3088            slug: String::new(),
3089            name: "Goner".to_string(),
3090            icon: "✦".to_string(),
3091            provider: "claude".to_string(),
3092            description: String::new(),
3093            working_directory: String::new(),
3094            shell: "bash".to_string(),
3095            provider_flags: String::new(),
3096            auto_start: 0,
3097            restart_on_crash: 0,
3098            idle_timeout_minutes: 0,
3099            created_at: 1000,
3100            agent_type: "standalone".to_string(),
3101            environment: String::new(),
3102            agent_bus_id: String::new(),
3103            is_seeded: 1,
3104            accounts: String::new(),
3105            parent_id: String::new(),
3106            branch_label: String::new(),
3107            updated_at: 1000,
3108            user_hidden: 0,
3109            container_image: String::new(),
3110            container_volumes: "[]".to_string(),
3111            container_name: String::new(),
3112        };
3113        store.agent_def_insert(&mut def).unwrap();
3114        assert_eq!(count_agents(&store, "id = 'tpl-del'"), 1);
3115        store.agent_def_delete("tpl-del").unwrap();
3116        assert_eq!(count_agents(&store, "id = 'tpl-del'"), 0);
3117    }
3118
3119    #[test]
3120    fn dual_write_instance_create_inserts_user_clone_row() {
3121        let store = make_store();
3122        // Seed template.
3123        let mut tpl = AgentDefinition {
3124            id: "tpl-for-inst".to_string(),
3125            slug: String::new(),
3126            name: "Coder".to_string(),
3127            icon: "✦".to_string(),
3128            provider: "claude".to_string(),
3129            description: "desc".to_string(),
3130            working_directory: "/wd/tpl-cfg".to_string(),
3131            shell: "bash".to_string(),
3132            provider_flags: String::new(),
3133            auto_start: 0,
3134            restart_on_crash: 0,
3135            idle_timeout_minutes: 0,
3136            created_at: 1000,
3137            agent_type: "standalone".to_string(),
3138            environment: String::new(),
3139            agent_bus_id: String::new(),
3140            is_seeded: 1,
3141            accounts: String::new(),
3142            parent_id: String::new(),
3143            branch_label: String::new(),
3144            updated_at: 1000,
3145            user_hidden: 0,
3146            container_image: String::new(),
3147            container_volumes: "[]".to_string(),
3148            container_name: String::new(),
3149        };
3150        store.agent_def_insert(&mut tpl).unwrap();
3151
3152        let inst = AgentInstance {
3153            id: "inst-dw".to_string(),
3154            definition_id: "tpl-for-inst".to_string(),
3155            parent_instance_id: String::new(),
3156            block_id: "blk-head".to_string(),
3157            session_id: String::new(),
3158            status: "running".to_string(),
3159            github_context: String::new(),
3160            started_at: 2000,
3161            ended_at: 0,
3162            created_at: 2000,
3163            identity_id: "id-1".to_string(),
3164            memory_id: "mem-1".to_string(),
3165            instance_name: "Maks".to_string(),
3166            working_directory: "/wd/maks".to_string(),
3167            display_hidden: false,
3168        };
3169        store.instance_create(&inst).unwrap();
3170        // Projected row: id == inst.id, is_template = 0, parent = tpl id,
3171        // bindings copied.
3172        assert_eq!(read_agent_int(&store, "inst-dw", "is_template"), Some(0));
3173        assert_eq!(
3174            read_agent_field(&store, "inst-dw", "parent_template_id"),
3175            Some("tpl-for-inst".to_string())
3176        );
3177        assert_eq!(read_agent_field(&store, "inst-dw", "name"), Some("Maks".to_string()));
3178        assert_eq!(read_agent_field(&store, "inst-dw", "identity_id"), Some("id-1".to_string()));
3179        assert_eq!(read_agent_field(&store, "inst-dw", "memory_id"), Some("mem-1".to_string()));
3180        // working_directory mirrors the DEFINITION's configured cwd, NOT the
3181        // instance's resolved workdir ("/wd/maks"). db_agents holds durable
3182        // agent config; the per-launch resolved cwd lives on the block.
3183        assert_eq!(read_agent_field(&store, "inst-dw", "working_directory"), Some("/wd/tpl-cfg".to_string()));
3184        // last_block_id mirrors the instance's per-launch block (the one
3185        // transient field db_agents retains, so My Agents can locate the
3186        // filestore snapshot). Non-empty value → non-vacuous assertion.
3187        assert_eq!(read_agent_field(&store, "inst-dw", "last_block_id"), Some("blk-head".to_string()));
3188        // Continuation rows skipped.
3189        let cont = AgentInstance {
3190            id: "inst-cont".to_string(),
3191            parent_instance_id: "inst-dw".to_string(),
3192            ..inst.clone()
3193        };
3194        store.instance_create(&cont).unwrap();
3195        assert_eq!(count_agents(&store, "id = 'inst-cont'"), 0);
3196    }
3197
3198    /// Reagent P1 + P2 on #1013 round 2 — pins the user-cloned-def
3199    /// branch of `agents_dual_write_instance_create` so it matches
3200    /// the backfill rule (`agents_consolidate.rs::backfill_instances`
3201    /// folds the instance's bindings into the EXISTING `db_agents`
3202    /// row keyed by `def.id`, NOT a fresh row keyed by `inst.id`).
3203    /// Round-1 test only covered the seeded-template branch.
3204    #[test]
3205    fn dual_write_instance_create_folds_into_user_clone_def() {
3206        let store = make_store();
3207        // Seed a template.
3208        let mut tpl = AgentDefinition {
3209            id: "tpl-folded".to_string(),
3210            slug: String::new(),
3211            name: "Coder".to_string(),
3212            icon: "✦".to_string(),
3213            provider: "claude".to_string(),
3214            description: "desc".to_string(),
3215            working_directory: String::new(),
3216            shell: "bash".to_string(),
3217            provider_flags: String::new(),
3218            auto_start: 0,
3219            restart_on_crash: 0,
3220            idle_timeout_minutes: 0,
3221            created_at: 1000,
3222            agent_type: "standalone".to_string(),
3223            environment: String::new(),
3224            agent_bus_id: String::new(),
3225            is_seeded: 1,
3226            accounts: String::new(),
3227            parent_id: String::new(),
3228            branch_label: String::new(),
3229            updated_at: 1000,
3230            user_hidden: 0,
3231            container_image: String::new(),
3232            container_volumes: "[]".to_string(),
3233            container_name: String::new(),
3234        };
3235        store.agent_def_insert(&mut tpl).unwrap();
3236
3237        // User-clone of the template (is_seeded = 0, parent_id = tpl id).
3238        let mut clone = AgentDefinition {
3239            id: "user-clone-1".to_string(),
3240            slug: String::new(),
3241            name: "Maks".to_string(),
3242            is_seeded: 0,
3243            parent_id: "tpl-folded".to_string(),
3244            working_directory: "/wd/clone-cfg".to_string(),
3245            created_at: 1500,
3246            updated_at: 1500,
3247            ..tpl.clone()
3248        };
3249        store.agent_def_insert(&mut clone).unwrap();
3250        // The user-clone projection in db_agents starts with empty bindings.
3251        assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some(String::new()));
3252        assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some(String::new()));
3253
3254        // Create an instance ON the user-clone def. Per backfill rule,
3255        // this must FOLD the instance's bindings into the existing
3256        // user-clone-1 row — NOT create a separate inst-fold-1 row
3257        // with parent_template_id pointing at a non-template row.
3258        let inst = AgentInstance {
3259            id: "inst-fold-1".to_string(),
3260            definition_id: "user-clone-1".to_string(),
3261            parent_instance_id: String::new(),
3262            block_id: "blk-fold".to_string(),
3263            session_id: String::new(),
3264            status: "running".to_string(),
3265            github_context: "gh-ctx-A".to_string(),
3266            started_at: 2000,
3267            ended_at: 0,
3268            created_at: 2000,
3269            identity_id: "id-folded".to_string(),
3270            memory_id: "mem-folded".to_string(),
3271            instance_name: "Maks v2".to_string(),
3272            working_directory: "/wd/folded".to_string(),
3273            display_hidden: false,
3274        };
3275        store.instance_create(&inst).unwrap();
3276
3277        // No new row keyed by inst.id — backfill never creates one for
3278        // user-clone-def instances, and dual-write must match.
3279        assert_eq!(count_agents(&store, "id = 'inst-fold-1'"), 0);
3280
3281        // Bindings folded onto the user-clone-1 row.
3282        assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some("id-folded".to_string()));
3283        assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some("mem-folded".to_string()));
3284        // identity_id / memory_id DO fold (per-instance bindings), but
3285        // working_directory does NOT — it stays the clone def's configured
3286        // cwd, not the instance's resolved workdir ("/wd/folded").
3287        assert_eq!(read_agent_field(&store, "user-clone-1", "working_directory"), Some("/wd/clone-cfg".to_string()));
3288        assert_eq!(read_agent_field(&store, "user-clone-1", "github_context"), Some("gh-ctx-A".to_string()));
3289        // last_block_id folds onto the user-clone row too (transient
3290        // per-launch field; non-empty → non-vacuous).
3291        assert_eq!(read_agent_field(&store, "user-clone-1", "last_block_id"), Some("blk-fold".to_string()));
3292        assert_eq!(read_agent_field(&store, "user-clone-1", "instance_name"), Some("Maks v2".to_string()));
3293        assert_eq!(read_agent_field(&store, "user-clone-1", "name"), Some("Maks v2".to_string()));
3294        // is_template stays 0, parent_template_id untouched (still empty
3295        // since user-clone insert leaves it blank).
3296        assert_eq!(read_agent_int(&store, "user-clone-1", "is_template"), Some(0));
3297    }
3298
3299    #[test]
3300    fn dual_write_instance_set_hidden_flips_user_hidden_bit() {
3301        let store = make_store();
3302        let mut tpl = AgentDefinition {
3303            id: "tpl-hide".to_string(),
3304            slug: String::new(),
3305            name: "Coder".to_string(),
3306            icon: "✦".to_string(),
3307            provider: "claude".to_string(),
3308            description: String::new(),
3309            working_directory: String::new(),
3310            shell: "bash".to_string(),
3311            provider_flags: String::new(),
3312            auto_start: 0,
3313            restart_on_crash: 0,
3314            idle_timeout_minutes: 0,
3315            created_at: 1000,
3316            agent_type: "standalone".to_string(),
3317            environment: String::new(),
3318            agent_bus_id: String::new(),
3319            is_seeded: 1,
3320            accounts: String::new(),
3321            parent_id: String::new(),
3322            branch_label: String::new(),
3323            updated_at: 1000,
3324            user_hidden: 0,
3325            container_image: String::new(),
3326            container_volumes: "[]".to_string(),
3327            container_name: String::new(),
3328        };
3329        store.agent_def_insert(&mut tpl).unwrap();
3330        let inst = AgentInstance {
3331            id: "inst-hide".to_string(),
3332            definition_id: "tpl-hide".to_string(),
3333            parent_instance_id: String::new(),
3334            block_id: String::new(),
3335            session_id: String::new(),
3336            status: "running".to_string(),
3337            github_context: String::new(),
3338            started_at: 2000,
3339            ended_at: 0,
3340            created_at: 2000,
3341            identity_id: String::new(),
3342            memory_id: String::new(),
3343            instance_name: "H".to_string(),
3344            working_directory: "/wd/h".to_string(),
3345            display_hidden: false,
3346        };
3347        store.instance_create(&inst).unwrap();
3348        assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
3349        store.instance_set_hidden("inst-hide", true).unwrap();
3350        assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(1));
3351        store.instance_set_hidden("inst-hide", false).unwrap();
3352        assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
3353    }
3354
3355    #[test]
3356    fn dual_write_instance_delete_drops_db_agents_row() {
3357        let store = make_store();
3358        let mut tpl = AgentDefinition {
3359            id: "tpl-instdel".to_string(),
3360            slug: String::new(),
3361            name: "Coder".to_string(),
3362            icon: "✦".to_string(),
3363            provider: "claude".to_string(),
3364            description: String::new(),
3365            working_directory: String::new(),
3366            shell: "bash".to_string(),
3367            provider_flags: String::new(),
3368            auto_start: 0,
3369            restart_on_crash: 0,
3370            idle_timeout_minutes: 0,
3371            created_at: 1000,
3372            agent_type: "standalone".to_string(),
3373            environment: String::new(),
3374            agent_bus_id: String::new(),
3375            is_seeded: 1,
3376            accounts: String::new(),
3377            parent_id: String::new(),
3378            branch_label: String::new(),
3379            updated_at: 1000,
3380            user_hidden: 0,
3381            container_image: String::new(),
3382            container_volumes: "[]".to_string(),
3383            container_name: String::new(),
3384        };
3385        store.agent_def_insert(&mut tpl).unwrap();
3386        let inst = AgentInstance {
3387            id: "inst-del".to_string(),
3388            definition_id: "tpl-instdel".to_string(),
3389            parent_instance_id: String::new(),
3390            block_id: String::new(),
3391            session_id: String::new(),
3392            status: "running".to_string(),
3393            github_context: String::new(),
3394            started_at: 2000,
3395            ended_at: 0,
3396            created_at: 2000,
3397            identity_id: String::new(),
3398            memory_id: String::new(),
3399            instance_name: "D".to_string(),
3400            working_directory: "/wd/d".to_string(),
3401            display_hidden: false,
3402        };
3403        store.instance_create(&inst).unwrap();
3404        assert_eq!(count_agents(&store, "id = 'inst-del'"), 1);
3405        store.instance_delete("inst-del").unwrap();
3406        assert_eq!(count_agents(&store, "id = 'inst-del'"), 0);
3407    }
3408
3409    #[test]
3410    fn dual_write_instance_repoint_updates_parent_template_id() {
3411        let store = make_store();
3412        let mut tpl_a = AgentDefinition {
3413            id: "tpl-A".to_string(),
3414            slug: String::new(),
3415            name: "A".to_string(),
3416            icon: "✦".to_string(),
3417            provider: "claude".to_string(),
3418            description: String::new(),
3419            working_directory: String::new(),
3420            shell: "bash".to_string(),
3421            provider_flags: String::new(),
3422            auto_start: 0,
3423            restart_on_crash: 0,
3424            idle_timeout_minutes: 0,
3425            created_at: 1000,
3426            agent_type: "standalone".to_string(),
3427            environment: String::new(),
3428            agent_bus_id: String::new(),
3429            is_seeded: 1,
3430            accounts: String::new(),
3431            parent_id: String::new(),
3432            branch_label: String::new(),
3433            updated_at: 1000,
3434            user_hidden: 0,
3435            container_image: String::new(),
3436            container_volumes: "[]".to_string(),
3437            container_name: String::new(),
3438        };
3439        store.agent_def_insert(&mut tpl_a).unwrap();
3440        let mut tpl_b = tpl_a.clone();
3441        tpl_b.id = "tpl-B".to_string();
3442        tpl_b.slug = String::new();
3443        store.agent_def_insert(&mut tpl_b).unwrap();
3444
3445        let inst = AgentInstance {
3446            id: "inst-rp".to_string(),
3447            definition_id: "tpl-A".to_string(),
3448            parent_instance_id: String::new(),
3449            block_id: String::new(),
3450            session_id: String::new(),
3451            status: "running".to_string(),
3452            github_context: String::new(),
3453            started_at: 2000,
3454            ended_at: 0,
3455            created_at: 2000,
3456            identity_id: String::new(),
3457            memory_id: String::new(),
3458            instance_name: "R".to_string(),
3459            working_directory: "/wd/r".to_string(),
3460            display_hidden: false,
3461        };
3462        store.instance_create(&inst).unwrap();
3463        assert_eq!(
3464            read_agent_field(&store, "inst-rp", "parent_template_id"),
3465            Some("tpl-A".to_string())
3466        );
3467        store.instance_repoint_definition("tpl-A", "tpl-B").unwrap();
3468        assert_eq!(
3469            read_agent_field(&store, "inst-rp", "parent_template_id"),
3470            Some("tpl-B".to_string())
3471        );
3472    }
3473
3474    #[test]
3475    fn dual_write_agent_def_delete_seeded_drops_all_template_rows() {
3476        let store = make_store();
3477        for id in &["s1", "s2", "s3"] {
3478            let mut d = AgentDefinition {
3479                id: id.to_string(),
3480                slug: String::new(),
3481                name: id.to_string(),
3482                icon: "✦".to_string(),
3483                provider: "claude".to_string(),
3484                description: String::new(),
3485                working_directory: String::new(),
3486                shell: "bash".to_string(),
3487                provider_flags: String::new(),
3488                auto_start: 0,
3489                restart_on_crash: 0,
3490                idle_timeout_minutes: 0,
3491                created_at: 1000,
3492                agent_type: "standalone".to_string(),
3493                environment: String::new(),
3494                agent_bus_id: String::new(),
3495                is_seeded: 1,
3496                accounts: String::new(),
3497                parent_id: String::new(),
3498                branch_label: String::new(),
3499                updated_at: 1000,
3500                user_hidden: 0,
3501            container_image: String::new(),
3502            container_volumes: "[]".to_string(),
3503            container_name: String::new(),
3504            };
3505            store.agent_def_insert(&mut d).unwrap();
3506        }
3507        assert_eq!(count_agents(&store, "is_template = 1"), 3);
3508        store.agent_def_delete_seeded().unwrap();
3509        assert_eq!(count_agents(&store, "is_template = 1"), 0);
3510    }
3511
3512    /// Reagent P2 round 4 on #1013 — pins the seeded-bulk-delete
3513    /// scope: templates + cascaded INSTANCE projections go;
3514    /// user-clone DEF projections survive.
3515    #[test]
3516    fn dual_write_seeded_delete_preserves_user_clone_def_projections() {
3517        let store = make_store();
3518        // Seeded template.
3519        let mut tpl = AgentDefinition {
3520            id: "tpl-keep-check".to_string(),
3521            slug: String::new(),
3522            name: "TplCheck".to_string(),
3523            icon: "✦".to_string(),
3524            provider: "claude".to_string(),
3525            description: String::new(),
3526            working_directory: String::new(),
3527            shell: "bash".to_string(),
3528            provider_flags: String::new(),
3529            auto_start: 0,
3530            restart_on_crash: 0,
3531            idle_timeout_minutes: 0,
3532            created_at: 1000,
3533            agent_type: "standalone".to_string(),
3534            environment: String::new(),
3535            agent_bus_id: String::new(),
3536            is_seeded: 1,
3537            accounts: String::new(),
3538            parent_id: String::new(),
3539            branch_label: String::new(),
3540            updated_at: 1000,
3541            user_hidden: 0,
3542            container_image: String::new(),
3543            container_volumes: "[]".to_string(),
3544            container_name: String::new(),
3545        };
3546        store.agent_def_insert(&mut tpl).unwrap();
3547        // User-clone DEF of that template (Phase 1 created this).
3548        let mut clone = AgentDefinition {
3549            id: "user-clone-keep".to_string(),
3550            slug: String::new(),
3551            name: "MaksKeeper".to_string(),
3552            is_seeded: 0,
3553            parent_id: "tpl-keep-check".to_string(),
3554            created_at: 1500,
3555            updated_at: 1500,
3556            ..tpl.clone()
3557        };
3558        store.agent_def_insert(&mut clone).unwrap();
3559        // Instance ON the seeded template (cascaded instance projection).
3560        let inst_on_tpl = AgentInstance {
3561            id: "inst-on-tpl-keep".to_string(),
3562            definition_id: "tpl-keep-check".to_string(),
3563            parent_instance_id: String::new(),
3564            block_id: String::new(),
3565            session_id: String::new(),
3566            status: "running".to_string(),
3567            github_context: String::new(),
3568            started_at: 2000,
3569            ended_at: 0,
3570            created_at: 2000,
3571            identity_id: String::new(),
3572            memory_id: String::new(),
3573            instance_name: String::new(),
3574            working_directory: String::new(),
3575            display_hidden: false,
3576        };
3577        store.instance_create(&inst_on_tpl).unwrap();
3578        // Now delete seeded → template + cascaded instance go, user-clone survives.
3579        store.agent_def_delete_seeded().unwrap();
3580        assert_eq!(count_agents(&store, "id = 'tpl-keep-check'"), 0, "template projection gone");
3581        assert_eq!(count_agents(&store, "id = 'inst-on-tpl-keep'"), 0, "cascaded instance projection gone");
3582        assert_eq!(count_agents(&store, "id = 'user-clone-keep'"), 1, "user-clone def projection survives");
3583    }
3584
3585    /// Reagent P2 round 4 on #1013 — pins instance_update/hide/delete
3586    /// routing through the projection key. The previous version keyed
3587    /// everything on `inst.id` and silently no-op'd on folded rows.
3588    #[test]
3589    fn dual_write_instance_lifecycle_on_user_clone_def_routes_to_folded_row() {
3590        let store = make_store();
3591        // Template, user-clone def of it, instance on the user-clone.
3592        let mut tpl = AgentDefinition {
3593            id: "tpl-rt".to_string(),
3594            slug: String::new(),
3595            name: "Tpl".to_string(),
3596            icon: "✦".to_string(),
3597            provider: "claude".to_string(),
3598            description: String::new(),
3599            working_directory: String::new(),
3600            shell: "bash".to_string(),
3601            provider_flags: String::new(),
3602            auto_start: 0,
3603            restart_on_crash: 0,
3604            idle_timeout_minutes: 0,
3605            created_at: 1000,
3606            agent_type: "standalone".to_string(),
3607            environment: String::new(),
3608            agent_bus_id: String::new(),
3609            is_seeded: 1,
3610            accounts: String::new(),
3611            parent_id: String::new(),
3612            branch_label: String::new(),
3613            updated_at: 1000,
3614            user_hidden: 0,
3615            container_image: String::new(),
3616            container_volumes: "[]".to_string(),
3617            container_name: String::new(),
3618        };
3619        store.agent_def_insert(&mut tpl).unwrap();
3620        let mut clone = AgentDefinition {
3621            id: "user-rt".to_string(),
3622            slug: String::new(),
3623            name: "Maks".to_string(),
3624            is_seeded: 0,
3625            parent_id: "tpl-rt".to_string(),
3626            created_at: 1500,
3627            updated_at: 1500,
3628            ..tpl.clone()
3629        };
3630        store.agent_def_insert(&mut clone).unwrap();
3631        let inst = AgentInstance {
3632            id: "inst-rt".to_string(),
3633            definition_id: "user-rt".to_string(),
3634            parent_instance_id: String::new(),
3635            block_id: String::new(),
3636            session_id: String::new(),
3637            status: "running".to_string(),
3638            github_context: "gh-initial".to_string(),
3639            started_at: 2000,
3640            ended_at: 0,
3641            created_at: 2000,
3642            identity_id: "id-init".to_string(),
3643            memory_id: "mem-init".to_string(),
3644            instance_name: "Maks v1".to_string(),
3645            working_directory: "/wd".to_string(),
3646            display_hidden: false,
3647        };
3648        store.instance_create(&inst).unwrap();
3649        // Sanity: no inst-rt row (folded).
3650        assert_eq!(count_agents(&store, "id = 'inst-rt'"), 0);
3651        assert_eq!(read_agent_field(&store, "user-rt", "github_context"), Some("gh-initial".to_string()));
3652
3653        // instance_update: github_context flows through to the folded row.
3654        let updated = AgentInstance {
3655            github_context: "gh-updated".to_string(),
3656            ..inst.clone()
3657        };
3658        store.instance_update(&updated).unwrap();
3659        assert_eq!(
3660            read_agent_field(&store, "user-rt", "github_context"),
3661            Some("gh-updated".to_string()),
3662            "instance_update on user-clone-def routes to folded row",
3663        );
3664
3665        // instance_set_hidden: flips user_hidden on the folded row.
3666        store.instance_set_hidden("inst-rt", true).unwrap();
3667        assert_eq!(
3668            read_agent_int(&store, "user-rt", "user_hidden"),
3669            Some(1),
3670            "instance_set_hidden routes to folded row",
3671        );
3672
3673        // instance_delete: NO-OP on folded row (the def projection persists).
3674        store.instance_delete("inst-rt").unwrap();
3675        assert_eq!(
3676            count_agents(&store, "id = 'user-rt'"),
3677            1,
3678            "instance_delete on user-clone-def is a no-op (def projection persists)",
3679        );
3680    }
3681}